signalwire-agents 0.1.28__py3-none-any.whl → 0.1.30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. signalwire_agents/__init__.py +1 -1
  2. signalwire_agents/cli/__init__.py +9 -0
  3. signalwire_agents/cli/config.py +9 -0
  4. signalwire_agents/cli/core/__init__.py +9 -0
  5. signalwire_agents/cli/core/agent_loader.py +9 -0
  6. signalwire_agents/cli/core/argparse_helpers.py +9 -0
  7. signalwire_agents/cli/core/dynamic_config.py +9 -0
  8. signalwire_agents/cli/execution/__init__.py +9 -0
  9. signalwire_agents/cli/execution/datamap_exec.py +9 -0
  10. signalwire_agents/cli/execution/webhook_exec.py +9 -0
  11. signalwire_agents/cli/output/__init__.py +9 -0
  12. signalwire_agents/cli/output/output_formatter.py +9 -0
  13. signalwire_agents/cli/output/swml_dump.py +9 -0
  14. signalwire_agents/cli/simulation/__init__.py +9 -0
  15. signalwire_agents/cli/simulation/data_generation.py +9 -0
  16. signalwire_agents/cli/simulation/data_overrides.py +9 -0
  17. signalwire_agents/cli/simulation/mock_env.py +9 -0
  18. signalwire_agents/cli/test_swaig.py +9 -0
  19. signalwire_agents/cli/types.py +9 -0
  20. signalwire_agents/core/agent/deployment/__init__.py +9 -0
  21. signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
  22. signalwire_agents/core/agent/routing/__init__.py +9 -0
  23. signalwire_agents/core/agent/security/__init__.py +9 -0
  24. signalwire_agents/core/agent/swml/__init__.py +9 -0
  25. signalwire_agents/core/auth_handler.py +233 -0
  26. signalwire_agents/core/config_loader.py +259 -0
  27. signalwire_agents/core/contexts.py +84 -0
  28. signalwire_agents/core/security_config.py +333 -0
  29. signalwire_agents/core/swml_service.py +19 -25
  30. signalwire_agents/search/search_service.py +200 -11
  31. signalwire_agents/skills/__init__.py +9 -0
  32. signalwire_agents/skills/api_ninjas_trivia/__init__.py +9 -0
  33. signalwire_agents/skills/api_ninjas_trivia/skill.py +9 -0
  34. signalwire_agents/skills/datasphere_serverless/__init__.py +9 -0
  35. signalwire_agents/skills/datetime/__init__.py +9 -0
  36. signalwire_agents/skills/joke/__init__.py +9 -0
  37. signalwire_agents/skills/math/__init__.py +9 -0
  38. signalwire_agents/skills/mcp_gateway/__init__.py +9 -0
  39. signalwire_agents/skills/native_vector_search/__init__.py +9 -0
  40. signalwire_agents/skills/play_background_file/__init__.py +9 -0
  41. signalwire_agents/skills/play_background_file/skill.py +9 -0
  42. signalwire_agents/skills/spider/__init__.py +9 -0
  43. signalwire_agents/skills/spider/skill.py +9 -0
  44. signalwire_agents/skills/swml_transfer/__init__.py +9 -0
  45. signalwire_agents/skills/weather_api/__init__.py +9 -0
  46. signalwire_agents/skills/weather_api/skill.py +9 -0
  47. signalwire_agents/skills/web_search/__init__.py +9 -0
  48. signalwire_agents/skills/wikipedia_search/__init__.py +9 -0
  49. signalwire_agents/skills/wikipedia_search/skill.py +9 -0
  50. {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/METADATA +1 -1
  51. {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/RECORD +55 -52
  52. {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/WHEEL +0 -0
  53. {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/entry_points.txt +0 -0
  54. {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/licenses/LICENSE +0 -0
  55. {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,259 @@
1
+ """
2
+ Copyright (c) 2025 SignalWire
3
+
4
+ This file is part of the SignalWire AI Agents SDK.
5
+
6
+ Licensed under the MIT License.
7
+ See LICENSE file in the project root for full license information.
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import json
13
+ from typing import Any, Dict, List, Optional, Union
14
+ from signalwire_agents.core.logging_config import get_logger
15
+
16
+ logger = get_logger("config_loader")
17
+
18
+
19
+ class ConfigLoader:
20
+ """
21
+ Configuration loader with environment variable substitution.
22
+
23
+ Supports ${VAR|default} syntax for referencing environment variables
24
+ within JSON configuration files. This provides a clean pattern for
25
+ configuration across all SignalWire services.
26
+ """
27
+
28
+ def __init__(self, config_paths: Optional[List[str]] = None):
29
+ """
30
+ Initialize config loader.
31
+
32
+ Args:
33
+ config_paths: Optional list of config file paths to check.
34
+ If not provided, uses default search paths.
35
+ """
36
+ self.config_paths = config_paths or self._get_default_paths()
37
+ self._config = None
38
+ self._config_file = None
39
+ self._load_config()
40
+
41
+ def _get_default_paths(self) -> List[str]:
42
+ """Get default configuration file search paths."""
43
+ return [
44
+ "config.json",
45
+ "agent_config.json",
46
+ "swml_config.json",
47
+ ".swml/config.json",
48
+ os.path.expanduser("~/.swml/config.json"),
49
+ "/etc/swml/config.json"
50
+ ]
51
+
52
+ def _load_config(self) -> None:
53
+ """Load configuration from the first available config file."""
54
+ for path in self.config_paths:
55
+ if os.path.exists(path):
56
+ try:
57
+ with open(path, 'r') as f:
58
+ self._config = json.load(f)
59
+ self._config_file = path
60
+ logger.info("config_loaded", path=path)
61
+ break
62
+ except Exception as e:
63
+ logger.error("config_load_error", path=path, error=str(e))
64
+
65
+ def has_config(self) -> bool:
66
+ """Check if a configuration was loaded."""
67
+ return self._config is not None
68
+
69
+ def get_config_file(self) -> Optional[str]:
70
+ """Get the path of the loaded config file."""
71
+ return self._config_file
72
+
73
+ def get_config(self) -> Dict[str, Any]:
74
+ """Get the raw configuration (before substitution)."""
75
+ return self._config or {}
76
+
77
+ def substitute_vars(self, value: Any) -> Any:
78
+ """
79
+ Recursively substitute environment variables in configuration values.
80
+
81
+ Supports ${VAR|default} syntax where:
82
+ - VAR is the environment variable name
83
+ - default is the fallback value if VAR is not set
84
+
85
+ Args:
86
+ value: The value to process (can be string, dict, list, etc.)
87
+
88
+ Returns:
89
+ The value with all environment variables substituted
90
+ """
91
+ if isinstance(value, str):
92
+ # Pattern to match ${VAR} or ${VAR|default}
93
+ pattern = r'\$\{([^}|]+)(?:\|([^}]*))?\}'
94
+
95
+ def replacer(match):
96
+ var_name = match.group(1)
97
+ default = match.group(2) if match.group(2) is not None else ''
98
+ return os.environ.get(var_name, default)
99
+
100
+ # Substitute all variables
101
+ result = re.sub(pattern, replacer, value)
102
+
103
+ # Try to parse as JSON to get proper types
104
+ if result.lower() in ('true', 'false'):
105
+ return result.lower() == 'true'
106
+ elif result.isdigit():
107
+ return int(result)
108
+ elif result.replace('.', '', 1).isdigit():
109
+ return float(result)
110
+ else:
111
+ return result
112
+
113
+ elif isinstance(value, dict):
114
+ # Recursively process dictionary
115
+ return {k: self.substitute_vars(v) for k, v in value.items()}
116
+
117
+ elif isinstance(value, list):
118
+ # Recursively process list
119
+ return [self.substitute_vars(item) for item in value]
120
+
121
+ else:
122
+ # Return other types as-is
123
+ return value
124
+
125
+ def get(self, key_path: str, default: Any = None) -> Any:
126
+ """
127
+ Get a configuration value by dot-notation path.
128
+
129
+ Args:
130
+ key_path: Dot-separated path (e.g., "security.ssl_enabled")
131
+ default: Default value if path not found
132
+
133
+ Returns:
134
+ The configuration value with variables substituted
135
+ """
136
+ if not self._config:
137
+ return default
138
+
139
+ # Navigate through the config using the dot path
140
+ keys = key_path.split('.')
141
+ value = self._config
142
+
143
+ for key in keys:
144
+ if isinstance(value, dict) and key in value:
145
+ value = value[key]
146
+ else:
147
+ return default
148
+
149
+ # Substitute variables before returning
150
+ return self.substitute_vars(value)
151
+
152
+ def get_section(self, section: str) -> Dict[str, Any]:
153
+ """
154
+ Get an entire configuration section.
155
+
156
+ Args:
157
+ section: The section name (e.g., "security", "server")
158
+
159
+ Returns:
160
+ The configuration section with all variables substituted
161
+ """
162
+ if not self._config or section not in self._config:
163
+ return {}
164
+
165
+ return self.substitute_vars(self._config[section])
166
+
167
+ def merge_with_env(self, env_prefix: str = "SWML_") -> Dict[str, Any]:
168
+ """
169
+ Merge configuration with environment variables.
170
+
171
+ Config file takes precedence over environment variables,
172
+ but config can reference env vars via substitution.
173
+
174
+ Args:
175
+ env_prefix: Prefix for environment variables to consider
176
+
177
+ Returns:
178
+ Merged configuration dictionary
179
+ """
180
+ # Start with substituted config
181
+ result = self.substitute_vars(self._config) if self._config else {}
182
+
183
+ # Only add env vars that aren't already in config
184
+ # This preserves config file precedence
185
+ for key, value in os.environ.items():
186
+ if key.startswith(env_prefix):
187
+ # Convert SWML_SSL_ENABLED to ssl_enabled
188
+ config_key = key[len(env_prefix):].lower()
189
+
190
+ # Only set if not already in config
191
+ if not self._has_nested_key(result, config_key):
192
+ self._set_nested_key(result, config_key, value)
193
+
194
+ return result
195
+
196
+ def _has_nested_key(self, data: Dict, key_path: str) -> bool:
197
+ """Check if a nested key exists in dictionary."""
198
+ keys = key_path.split('_')
199
+ current = data
200
+
201
+ for key in keys:
202
+ if isinstance(current, dict) and key in current:
203
+ current = current[key]
204
+ else:
205
+ return False
206
+ return True
207
+
208
+ def _set_nested_key(self, data: Dict, key_path: str, value: Any) -> None:
209
+ """Set a value in dictionary using underscore-separated path."""
210
+ keys = key_path.split('_')
211
+ current = data
212
+
213
+ for key in keys[:-1]:
214
+ if key not in current:
215
+ current[key] = {}
216
+ current = current[key]
217
+
218
+ current[keys[-1]] = value
219
+
220
+ @staticmethod
221
+ def find_config_file(service_name: Optional[str] = None,
222
+ additional_paths: Optional[List[str]] = None) -> Optional[str]:
223
+ """
224
+ Static method to find a config file for a service.
225
+
226
+ Args:
227
+ service_name: Optional service name for service-specific config
228
+ additional_paths: Additional paths to check
229
+
230
+ Returns:
231
+ Path to the first config file found, or None
232
+ """
233
+ paths = []
234
+
235
+ # Service-specific config
236
+ if service_name:
237
+ paths.extend([
238
+ f"{service_name}_config.json",
239
+ f".swml/{service_name}_config.json"
240
+ ])
241
+
242
+ # Additional paths
243
+ if additional_paths:
244
+ paths.extend(additional_paths)
245
+
246
+ # Default paths
247
+ paths.extend([
248
+ "config.json",
249
+ "agent_config.json",
250
+ ".swml/config.json",
251
+ os.path.expanduser("~/.swml/config.json"),
252
+ "/etc/swml/config.json"
253
+ ])
254
+
255
+ for path in paths:
256
+ if os.path.exists(path):
257
+ return path
258
+
259
+ return None
@@ -1,3 +1,12 @@
1
+ """
2
+ Copyright (c) 2025 SignalWire
3
+
4
+ This file is part of the SignalWire AI Agents SDK.
5
+
6
+ Licensed under the MIT License.
7
+ See LICENSE file in the project root for full license information.
8
+ """
9
+
1
10
  """
2
11
  Contexts and Steps System for SignalWire Agents
3
12
 
@@ -259,6 +268,10 @@ class Context:
259
268
  # Context prompt (separate from system_prompt)
260
269
  self._prompt_text: Optional[str] = None
261
270
  self._prompt_sections: List[Dict[str, Any]] = []
271
+
272
+ # Context fillers
273
+ self._enter_fillers: Optional[Dict[str, List[str]]] = None
274
+ self._exit_fillers: Optional[Dict[str, List[str]]] = None
262
275
 
263
276
  def add_step(self, name: str) -> Step:
264
277
  """
@@ -450,6 +463,70 @@ class Context:
450
463
  self._prompt_sections.append({"title": title, "bullets": bullets})
451
464
  return self
452
465
 
466
+ def set_enter_fillers(self, enter_fillers: Dict[str, List[str]]) -> 'Context':
467
+ """
468
+ Set fillers that the AI says when entering this context
469
+
470
+ Args:
471
+ enter_fillers: Dictionary mapping language codes (or "default") to lists of filler phrases
472
+ Example: {"en-US": ["Welcome...", "Hello..."], "default": ["Entering..."]}
473
+
474
+ Returns:
475
+ Self for method chaining
476
+ """
477
+ if enter_fillers and isinstance(enter_fillers, dict):
478
+ self._enter_fillers = enter_fillers
479
+ return self
480
+
481
+ def set_exit_fillers(self, exit_fillers: Dict[str, List[str]]) -> 'Context':
482
+ """
483
+ Set fillers that the AI says when exiting this context
484
+
485
+ Args:
486
+ exit_fillers: Dictionary mapping language codes (or "default") to lists of filler phrases
487
+ Example: {"en-US": ["Goodbye...", "Thank you..."], "default": ["Exiting..."]}
488
+
489
+ Returns:
490
+ Self for method chaining
491
+ """
492
+ if exit_fillers and isinstance(exit_fillers, dict):
493
+ self._exit_fillers = exit_fillers
494
+ return self
495
+
496
+ def add_enter_filler(self, language_code: str, fillers: List[str]) -> 'Context':
497
+ """
498
+ Add enter fillers for a specific language
499
+
500
+ Args:
501
+ language_code: Language code (e.g., "en-US", "es") or "default" for catch-all
502
+ fillers: List of filler phrases for entering this context
503
+
504
+ Returns:
505
+ Self for method chaining
506
+ """
507
+ if language_code and fillers and isinstance(fillers, list):
508
+ if self._enter_fillers is None:
509
+ self._enter_fillers = {}
510
+ self._enter_fillers[language_code] = fillers
511
+ return self
512
+
513
+ def add_exit_filler(self, language_code: str, fillers: List[str]) -> 'Context':
514
+ """
515
+ Add exit fillers for a specific language
516
+
517
+ Args:
518
+ language_code: Language code (e.g., "en-US", "es") or "default" for catch-all
519
+ fillers: List of filler phrases for exiting this context
520
+
521
+ Returns:
522
+ Self for method chaining
523
+ """
524
+ if language_code and fillers and isinstance(fillers, list):
525
+ if self._exit_fillers is None:
526
+ self._exit_fillers = {}
527
+ self._exit_fillers[language_code] = fillers
528
+ return self
529
+
453
530
  def _render_prompt(self) -> Optional[str]:
454
531
  """Render the context's prompt text"""
455
532
  if self._prompt_text is not None:
@@ -533,6 +610,13 @@ class Context:
533
610
  elif self._prompt_text is not None:
534
611
  # Use string format
535
612
  context_dict["prompt"] = self._prompt_text
613
+
614
+ # Add enter and exit fillers if defined
615
+ if self._enter_fillers is not None:
616
+ context_dict["enter_fillers"] = self._enter_fillers
617
+
618
+ if self._exit_fillers is not None:
619
+ context_dict["exit_fillers"] = self._exit_fillers
536
620
 
537
621
  return context_dict
538
622
 
@@ -0,0 +1,333 @@
1
+ """
2
+ Copyright (c) 2025 SignalWire
3
+
4
+ This file is part of the SignalWire AI Agents SDK.
5
+
6
+ Licensed under the MIT License.
7
+ See LICENSE file in the project root for full license information.
8
+ """
9
+
10
+ import os
11
+ import secrets
12
+ from typing import Dict, Any, Optional, Tuple, List, Union
13
+ from signalwire_agents.core.logging_config import get_logger
14
+ from signalwire_agents.core.config_loader import ConfigLoader
15
+
16
+ logger = get_logger("security_config")
17
+
18
+
19
+ class SecurityConfig:
20
+ """
21
+ Unified security configuration for SignalWire services.
22
+
23
+ This class provides centralized security settings that can be used by
24
+ both SWML and Search services, ensuring consistent security behavior.
25
+ """
26
+
27
+ # Security environment variable names
28
+ SSL_ENABLED = 'SWML_SSL_ENABLED'
29
+ SSL_CERT_PATH = 'SWML_SSL_CERT_PATH'
30
+ SSL_KEY_PATH = 'SWML_SSL_KEY_PATH'
31
+ SSL_DOMAIN = 'SWML_DOMAIN'
32
+ SSL_VERIFY_MODE = 'SWML_SSL_VERIFY_MODE'
33
+
34
+ # Additional security settings
35
+ ALLOWED_HOSTS = 'SWML_ALLOWED_HOSTS'
36
+ CORS_ORIGINS = 'SWML_CORS_ORIGINS'
37
+ MAX_REQUEST_SIZE = 'SWML_MAX_REQUEST_SIZE'
38
+ RATE_LIMIT = 'SWML_RATE_LIMIT'
39
+ REQUEST_TIMEOUT = 'SWML_REQUEST_TIMEOUT'
40
+ USE_HSTS = 'SWML_USE_HSTS'
41
+ HSTS_MAX_AGE = 'SWML_HSTS_MAX_AGE'
42
+
43
+ # Authentication
44
+ BASIC_AUTH_USER = 'SWML_BASIC_AUTH_USER'
45
+ BASIC_AUTH_PASSWORD = 'SWML_BASIC_AUTH_PASSWORD'
46
+
47
+ # Defaults (secure by default)
48
+ DEFAULTS = {
49
+ SSL_ENABLED: False, # Off by default, but secure when enabled
50
+ SSL_VERIFY_MODE: 'CERT_REQUIRED',
51
+ ALLOWED_HOSTS: '*', # Accept all hosts by default for backward compatibility
52
+ CORS_ORIGINS: '*', # Accept all origins by default for backward compatibility
53
+ MAX_REQUEST_SIZE: 10 * 1024 * 1024, # 10MB
54
+ RATE_LIMIT: 60, # Requests per minute
55
+ REQUEST_TIMEOUT: 30, # Seconds
56
+ USE_HSTS: True, # Enable HSTS when HTTPS is on
57
+ HSTS_MAX_AGE: 31536000, # 1 year
58
+ }
59
+
60
+ def __init__(self, config_file: Optional[str] = None, service_name: Optional[str] = None):
61
+ """
62
+ Initialize security configuration.
63
+
64
+ Args:
65
+ config_file: Optional path to config file
66
+ service_name: Optional service name for finding service-specific config
67
+ """
68
+ # First, set defaults
69
+ self._set_defaults()
70
+
71
+ # Then load from environment variables (backward compatibility)
72
+ self.load_from_env()
73
+
74
+ # Finally, apply config file if available (highest priority)
75
+ self._load_config_file(config_file, service_name)
76
+
77
+ def _set_defaults(self):
78
+ """Set default values for all configuration"""
79
+ # SSL configuration
80
+ self.ssl_enabled = self.DEFAULTS[self.SSL_ENABLED]
81
+ self.ssl_cert_path = None
82
+ self.ssl_key_path = None
83
+ self.domain = None
84
+ self.ssl_verify_mode = self.DEFAULTS[self.SSL_VERIFY_MODE]
85
+
86
+ # Additional settings
87
+ self.allowed_hosts = self._parse_list(self.DEFAULTS[self.ALLOWED_HOSTS])
88
+ self.cors_origins = self._parse_list(self.DEFAULTS[self.CORS_ORIGINS])
89
+ self.max_request_size = self.DEFAULTS[self.MAX_REQUEST_SIZE]
90
+ self.rate_limit = self.DEFAULTS[self.RATE_LIMIT]
91
+ self.request_timeout = self.DEFAULTS[self.REQUEST_TIMEOUT]
92
+ self.use_hsts = self.DEFAULTS[self.USE_HSTS]
93
+ self.hsts_max_age = self.DEFAULTS[self.HSTS_MAX_AGE]
94
+
95
+ # Authentication
96
+ self.basic_auth_user = None
97
+ self.basic_auth_password = None
98
+
99
+ def _load_config_file(self, config_file: Optional[str], service_name: Optional[str]):
100
+ """Load configuration from config file if available"""
101
+ # Find config file
102
+ if not config_file:
103
+ config_file = ConfigLoader.find_config_file(service_name)
104
+
105
+ if not config_file:
106
+ return
107
+
108
+ # Load config
109
+ config_loader = ConfigLoader([config_file])
110
+ if not config_loader.has_config():
111
+ return
112
+
113
+ logger.info("loading_config_from_file", file=config_file)
114
+
115
+ # Get security section
116
+ security_config = config_loader.get_section('security')
117
+ if not security_config:
118
+ return
119
+
120
+ # Apply security settings (config file takes precedence)
121
+ if 'ssl_enabled' in security_config:
122
+ self.ssl_enabled = security_config['ssl_enabled']
123
+
124
+ if 'ssl_cert_path' in security_config:
125
+ self.ssl_cert_path = security_config['ssl_cert_path']
126
+
127
+ if 'ssl_key_path' in security_config:
128
+ self.ssl_key_path = security_config['ssl_key_path']
129
+
130
+ if 'domain' in security_config:
131
+ self.domain = security_config['domain']
132
+
133
+ if 'ssl_verify_mode' in security_config:
134
+ self.ssl_verify_mode = security_config['ssl_verify_mode']
135
+
136
+ # Additional settings
137
+ if 'allowed_hosts' in security_config:
138
+ self.allowed_hosts = self._parse_list(security_config['allowed_hosts'])
139
+
140
+ if 'cors_origins' in security_config:
141
+ self.cors_origins = self._parse_list(security_config['cors_origins'])
142
+
143
+ if 'max_request_size' in security_config:
144
+ self.max_request_size = int(security_config['max_request_size'])
145
+
146
+ if 'rate_limit' in security_config:
147
+ self.rate_limit = int(security_config['rate_limit'])
148
+
149
+ if 'request_timeout' in security_config:
150
+ self.request_timeout = int(security_config['request_timeout'])
151
+
152
+ if 'use_hsts' in security_config:
153
+ self.use_hsts = security_config['use_hsts']
154
+
155
+ if 'hsts_max_age' in security_config:
156
+ self.hsts_max_age = int(security_config['hsts_max_age'])
157
+
158
+ # Authentication from config
159
+ auth_config = security_config.get('auth', {})
160
+ if isinstance(auth_config, dict):
161
+ basic_auth = auth_config.get('basic', {})
162
+ if isinstance(basic_auth, dict):
163
+ if 'user' in basic_auth:
164
+ self.basic_auth_user = basic_auth['user']
165
+ if 'password' in basic_auth:
166
+ self.basic_auth_password = basic_auth['password']
167
+
168
+ def load_from_env(self):
169
+ """Load configuration from environment variables"""
170
+ # SSL configuration
171
+ ssl_enabled_env = os.environ.get(self.SSL_ENABLED, '').lower()
172
+ self.ssl_enabled = ssl_enabled_env in ('true', '1', 'yes')
173
+ self.ssl_cert_path = os.environ.get(self.SSL_CERT_PATH)
174
+ self.ssl_key_path = os.environ.get(self.SSL_KEY_PATH)
175
+ self.domain = os.environ.get(self.SSL_DOMAIN)
176
+ self.ssl_verify_mode = os.environ.get(self.SSL_VERIFY_MODE, self.DEFAULTS[self.SSL_VERIFY_MODE])
177
+
178
+ # Additional security settings
179
+ self.allowed_hosts = self._parse_list(os.environ.get(self.ALLOWED_HOSTS, self.DEFAULTS[self.ALLOWED_HOSTS]))
180
+ self.cors_origins = self._parse_list(os.environ.get(self.CORS_ORIGINS, self.DEFAULTS[self.CORS_ORIGINS]))
181
+ self.max_request_size = int(os.environ.get(self.MAX_REQUEST_SIZE, self.DEFAULTS[self.MAX_REQUEST_SIZE]))
182
+ self.rate_limit = int(os.environ.get(self.RATE_LIMIT, self.DEFAULTS[self.RATE_LIMIT]))
183
+ self.request_timeout = int(os.environ.get(self.REQUEST_TIMEOUT, self.DEFAULTS[self.REQUEST_TIMEOUT]))
184
+
185
+ # HSTS settings
186
+ use_hsts_env = os.environ.get(self.USE_HSTS, '').lower()
187
+ self.use_hsts = use_hsts_env != 'false' if use_hsts_env else self.DEFAULTS[self.USE_HSTS]
188
+ self.hsts_max_age = int(os.environ.get(self.HSTS_MAX_AGE, self.DEFAULTS[self.HSTS_MAX_AGE]))
189
+
190
+ # Authentication
191
+ self.basic_auth_user = os.environ.get(self.BASIC_AUTH_USER)
192
+ self.basic_auth_password = os.environ.get(self.BASIC_AUTH_PASSWORD)
193
+
194
+ def _parse_list(self, value: Union[str, list]) -> list:
195
+ """Parse comma-separated list from environment variable or list from config"""
196
+ if isinstance(value, list):
197
+ return value
198
+ if value == '*':
199
+ return ['*']
200
+ return [item.strip() for item in value.split(',') if item.strip()]
201
+
202
+ def validate_ssl_config(self) -> Tuple[bool, Optional[str]]:
203
+ """
204
+ Validate SSL configuration.
205
+
206
+ Returns:
207
+ Tuple of (is_valid, error_message)
208
+ """
209
+ if not self.ssl_enabled:
210
+ return True, None
211
+
212
+ if not self.ssl_cert_path:
213
+ return False, "SSL enabled but SWML_SSL_CERT_PATH not set"
214
+
215
+ if not self.ssl_key_path:
216
+ return False, "SSL enabled but SWML_SSL_KEY_PATH not set"
217
+
218
+ if not os.path.exists(self.ssl_cert_path):
219
+ return False, f"SSL certificate file not found: {self.ssl_cert_path}"
220
+
221
+ if not os.path.exists(self.ssl_key_path):
222
+ return False, f"SSL key file not found: {self.ssl_key_path}"
223
+
224
+ return True, None
225
+
226
+ def get_ssl_context_kwargs(self) -> Dict[str, Any]:
227
+ """
228
+ Get SSL context kwargs for uvicorn.
229
+
230
+ Returns:
231
+ Dictionary of SSL parameters for uvicorn
232
+ """
233
+ if not self.ssl_enabled:
234
+ return {}
235
+
236
+ is_valid, error = self.validate_ssl_config()
237
+ if not is_valid:
238
+ logger.error("ssl_validation_failed", error=error)
239
+ return {}
240
+
241
+ return {
242
+ 'ssl_certfile': self.ssl_cert_path,
243
+ 'ssl_keyfile': self.ssl_key_path,
244
+ # Additional SSL options can be added here
245
+ }
246
+
247
+ def get_basic_auth(self) -> Tuple[str, str]:
248
+ """
249
+ Get basic auth credentials, generating if not set.
250
+
251
+ Returns:
252
+ Tuple of (username, password)
253
+ """
254
+ username = self.basic_auth_user or "signalwire"
255
+ password = self.basic_auth_password or secrets.token_urlsafe(32)
256
+
257
+ return username, password
258
+
259
+ def get_security_headers(self, is_https: bool = False) -> Dict[str, str]:
260
+ """
261
+ Get security headers to add to responses.
262
+
263
+ Args:
264
+ is_https: Whether the connection is over HTTPS
265
+
266
+ Returns:
267
+ Dictionary of security headers
268
+ """
269
+ headers = {
270
+ 'X-Content-Type-Options': 'nosniff',
271
+ 'X-Frame-Options': 'DENY',
272
+ 'X-XSS-Protection': '1; mode=block',
273
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
274
+ }
275
+
276
+ # Add HSTS header if HTTPS and enabled
277
+ if is_https and self.use_hsts:
278
+ headers['Strict-Transport-Security'] = f'max-age={self.hsts_max_age}; includeSubDomains'
279
+
280
+ return headers
281
+
282
+ def should_allow_host(self, host: str) -> bool:
283
+ """
284
+ Check if a host is allowed.
285
+
286
+ Args:
287
+ host: The host to check
288
+
289
+ Returns:
290
+ True if the host is allowed
291
+ """
292
+ if '*' in self.allowed_hosts:
293
+ return True
294
+
295
+ return host in self.allowed_hosts
296
+
297
+ def get_cors_config(self) -> Dict[str, Any]:
298
+ """
299
+ Get CORS configuration for FastAPI.
300
+
301
+ Returns:
302
+ Dictionary of CORS settings
303
+ """
304
+ return {
305
+ 'allow_origins': self.cors_origins,
306
+ 'allow_credentials': True,
307
+ 'allow_methods': ['*'],
308
+ 'allow_headers': ['*'],
309
+ }
310
+
311
+ def get_url_scheme(self) -> str:
312
+ """Get the URL scheme based on SSL configuration"""
313
+ return 'https' if self.ssl_enabled else 'http'
314
+
315
+ def log_config(self, service_name: str):
316
+ """Log the current security configuration"""
317
+ logger.info(
318
+ "security_config_loaded",
319
+ service=service_name,
320
+ ssl_enabled=self.ssl_enabled,
321
+ domain=self.domain,
322
+ allowed_hosts=self.allowed_hosts,
323
+ cors_origins=self.cors_origins,
324
+ max_request_size=self.max_request_size,
325
+ rate_limit=self.rate_limit,
326
+ use_hsts=self.use_hsts,
327
+ has_basic_auth=bool(self.basic_auth_user and self.basic_auth_password)
328
+ )
329
+
330
+
331
+ # Global instance for easy access (backward compatibility)
332
+ # Services can create their own instances with specific config files
333
+ security_config = SecurityConfig()