signalwire-agents 0.1.27__py3-none-any.whl → 0.1.29__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 (30) hide show
  1. signalwire_agents/__init__.py +1 -4
  2. signalwire_agents/cli/config.py +11 -1
  3. signalwire_agents/cli/simulation/data_overrides.py +6 -2
  4. signalwire_agents/cli/test_swaig.py +6 -0
  5. signalwire_agents/core/agent_base.py +1 -12
  6. signalwire_agents/core/auth_handler.py +233 -0
  7. signalwire_agents/core/config_loader.py +259 -0
  8. signalwire_agents/core/contexts.py +75 -0
  9. signalwire_agents/core/mixins/state_mixin.py +1 -67
  10. signalwire_agents/core/mixins/tool_mixin.py +0 -65
  11. signalwire_agents/core/security_config.py +333 -0
  12. signalwire_agents/core/swml_service.py +19 -25
  13. signalwire_agents/prefabs/concierge.py +0 -3
  14. signalwire_agents/prefabs/faq_bot.py +0 -3
  15. signalwire_agents/prefabs/info_gatherer.py +0 -3
  16. signalwire_agents/prefabs/receptionist.py +0 -3
  17. signalwire_agents/prefabs/survey.py +0 -3
  18. signalwire_agents/search/search_service.py +200 -11
  19. signalwire_agents/skills/mcp_gateway/README.md +230 -0
  20. signalwire_agents/skills/mcp_gateway/__init__.py +1 -0
  21. signalwire_agents/skills/mcp_gateway/skill.py +339 -0
  22. {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/METADATA +1 -59
  23. {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/RECORD +27 -24
  24. signalwire_agents/core/state/__init__.py +0 -17
  25. signalwire_agents/core/state/file_state_manager.py +0 -219
  26. signalwire_agents/core/state/state_manager.py +0 -101
  27. {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/WHEEL +0 -0
  28. {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/entry_points.txt +0 -0
  29. {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/licenses/LICENSE +0 -0
  30. {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/top_level.txt +0 -0
@@ -259,6 +259,10 @@ class Context:
259
259
  # Context prompt (separate from system_prompt)
260
260
  self._prompt_text: Optional[str] = None
261
261
  self._prompt_sections: List[Dict[str, Any]] = []
262
+
263
+ # Context fillers
264
+ self._enter_fillers: Optional[Dict[str, List[str]]] = None
265
+ self._exit_fillers: Optional[Dict[str, List[str]]] = None
262
266
 
263
267
  def add_step(self, name: str) -> Step:
264
268
  """
@@ -450,6 +454,70 @@ class Context:
450
454
  self._prompt_sections.append({"title": title, "bullets": bullets})
451
455
  return self
452
456
 
457
+ def set_enter_fillers(self, enter_fillers: Dict[str, List[str]]) -> 'Context':
458
+ """
459
+ Set fillers that the AI says when entering this context
460
+
461
+ Args:
462
+ enter_fillers: Dictionary mapping language codes (or "default") to lists of filler phrases
463
+ Example: {"en-US": ["Welcome...", "Hello..."], "default": ["Entering..."]}
464
+
465
+ Returns:
466
+ Self for method chaining
467
+ """
468
+ if enter_fillers and isinstance(enter_fillers, dict):
469
+ self._enter_fillers = enter_fillers
470
+ return self
471
+
472
+ def set_exit_fillers(self, exit_fillers: Dict[str, List[str]]) -> 'Context':
473
+ """
474
+ Set fillers that the AI says when exiting this context
475
+
476
+ Args:
477
+ exit_fillers: Dictionary mapping language codes (or "default") to lists of filler phrases
478
+ Example: {"en-US": ["Goodbye...", "Thank you..."], "default": ["Exiting..."]}
479
+
480
+ Returns:
481
+ Self for method chaining
482
+ """
483
+ if exit_fillers and isinstance(exit_fillers, dict):
484
+ self._exit_fillers = exit_fillers
485
+ return self
486
+
487
+ def add_enter_filler(self, language_code: str, fillers: List[str]) -> 'Context':
488
+ """
489
+ Add enter fillers for a specific language
490
+
491
+ Args:
492
+ language_code: Language code (e.g., "en-US", "es") or "default" for catch-all
493
+ fillers: List of filler phrases for entering this context
494
+
495
+ Returns:
496
+ Self for method chaining
497
+ """
498
+ if language_code and fillers and isinstance(fillers, list):
499
+ if self._enter_fillers is None:
500
+ self._enter_fillers = {}
501
+ self._enter_fillers[language_code] = fillers
502
+ return self
503
+
504
+ def add_exit_filler(self, language_code: str, fillers: List[str]) -> 'Context':
505
+ """
506
+ Add exit fillers for a specific language
507
+
508
+ Args:
509
+ language_code: Language code (e.g., "en-US", "es") or "default" for catch-all
510
+ fillers: List of filler phrases for exiting this context
511
+
512
+ Returns:
513
+ Self for method chaining
514
+ """
515
+ if language_code and fillers and isinstance(fillers, list):
516
+ if self._exit_fillers is None:
517
+ self._exit_fillers = {}
518
+ self._exit_fillers[language_code] = fillers
519
+ return self
520
+
453
521
  def _render_prompt(self) -> Optional[str]:
454
522
  """Render the context's prompt text"""
455
523
  if self._prompt_text is not None:
@@ -533,6 +601,13 @@ class Context:
533
601
  elif self._prompt_text is not None:
534
602
  # Use string format
535
603
  context_dict["prompt"] = self._prompt_text
604
+
605
+ # Add enter and exit fillers if defined
606
+ if self._enter_fillers is not None:
607
+ context_dict["enter_fillers"] = self._enter_fillers
608
+
609
+ if self._exit_fillers is not None:
610
+ context_dict["exit_fillers"] = self._exit_fillers
536
611
 
537
612
  return context_dict
538
613
 
@@ -150,70 +150,4 @@ class StateMixin:
150
150
  self.log.error("token_validation_error", error=str(e), function=function_name)
151
151
  return False
152
152
 
153
- # Note: set_dynamic_config_callback is implemented in WebMixin
154
-
155
- def _register_state_tracking_tools(self):
156
- """
157
- Register special tools for state tracking
158
-
159
- This adds startup_hook and hangup_hook SWAIG functions that automatically
160
- activate and deactivate the session when called. These are useful for
161
- tracking call state and cleaning up resources when a call ends.
162
- """
163
- # Register startup hook to activate session
164
- self.define_tool(
165
- name="startup_hook",
166
- description="Called when a new conversation starts to initialize state",
167
- parameters={},
168
- handler=lambda args, raw_data: self._handle_startup_hook(args, raw_data),
169
- secure=False # No auth needed for this system function
170
- )
171
-
172
- # Register hangup hook to end session
173
- self.define_tool(
174
- name="hangup_hook",
175
- description="Called when conversation ends to clean up resources",
176
- parameters={},
177
- handler=lambda args, raw_data: self._handle_hangup_hook(args, raw_data),
178
- secure=False # No auth needed for this system function
179
- )
180
-
181
- def _handle_startup_hook(self, args, raw_data):
182
- """
183
- Handle the startup hook function call
184
-
185
- Args:
186
- args: Function arguments (empty for this hook)
187
- raw_data: Raw request data containing call_id
188
-
189
- Returns:
190
- Success response
191
- """
192
- call_id = raw_data.get("call_id") if raw_data else None
193
- if call_id:
194
- self.log.info("session_activated", call_id=call_id)
195
- self._session_manager.activate_session(call_id)
196
- return SwaigFunctionResult("Session activated")
197
- else:
198
- self.log.warning("session_activation_failed", error="No call_id provided")
199
- return SwaigFunctionResult("Failed to activate session: No call_id provided")
200
-
201
- def _handle_hangup_hook(self, args, raw_data):
202
- """
203
- Handle the hangup hook function call
204
-
205
- Args:
206
- args: Function arguments (empty for this hook)
207
- raw_data: Raw request data containing call_id
208
-
209
- Returns:
210
- Success response
211
- """
212
- call_id = raw_data.get("call_id") if raw_data else None
213
- if call_id:
214
- self.log.info("session_ended", call_id=call_id)
215
- self._session_manager.end_session(call_id)
216
- return SwaigFunctionResult("Session ended")
217
- else:
218
- self.log.warning("session_end_failed", error="No call_id provided")
219
- return SwaigFunctionResult("Failed to end session: No call_id provided")
153
+ # Note: set_dynamic_config_callback is implemented in WebMixin
@@ -161,71 +161,6 @@ class ToolMixin:
161
161
  # If the handler raises an exception, return an error response
162
162
  return {"response": f"Error executing function '{name}': {str(e)}"}
163
163
 
164
- def _register_state_tracking_tools(self):
165
- """
166
- Register special tools for state tracking
167
-
168
- This adds startup_hook and hangup_hook SWAIG functions that automatically
169
- activate and deactivate the session when called. These are useful for
170
- tracking call state and cleaning up resources when a call ends.
171
- """
172
- # Register startup hook to activate session
173
- self.define_tool(
174
- name="startup_hook",
175
- description="Called when a new conversation starts to initialize state",
176
- parameters={},
177
- handler=lambda args, raw_data: self._handle_startup_hook(args, raw_data),
178
- secure=False # No auth needed for this system function
179
- )
180
-
181
- # Register hangup hook to end session
182
- self.define_tool(
183
- name="hangup_hook",
184
- description="Called when conversation ends to clean up resources",
185
- parameters={},
186
- handler=lambda args, raw_data: self._handle_hangup_hook(args, raw_data),
187
- secure=False # No auth needed for this system function
188
- )
189
-
190
- def _handle_startup_hook(self, args, raw_data):
191
- """
192
- Handle the startup hook function call
193
-
194
- Args:
195
- args: Function arguments (empty for this hook)
196
- raw_data: Raw request data containing call_id
197
-
198
- Returns:
199
- Success response
200
- """
201
- call_id = raw_data.get("call_id") if raw_data else None
202
- if call_id:
203
- self.log.info("session_activated", call_id=call_id)
204
- self._session_manager.activate_session(call_id)
205
- return SwaigFunctionResult("Session activated")
206
- else:
207
- self.log.warning("session_activation_failed", error="No call_id provided")
208
- return SwaigFunctionResult("Failed to activate session: No call_id provided")
209
-
210
- def _handle_hangup_hook(self, args, raw_data):
211
- """
212
- Handle the hangup hook function call
213
-
214
- Args:
215
- args: Function arguments (empty for this hook)
216
- raw_data: Raw request data containing call_id
217
-
218
- Returns:
219
- Success response
220
- """
221
- call_id = raw_data.get("call_id") if raw_data else None
222
- if call_id:
223
- self.log.info("session_ended", call_id=call_id)
224
- self._session_manager.end_session(call_id)
225
- return SwaigFunctionResult("Session ended")
226
- else:
227
- self.log.warning("session_end_failed", error="No call_id provided")
228
- return SwaigFunctionResult("Failed to end session: No call_id provided")
229
164
 
230
165
  def _execute_swaig_function(self, function_name: str, args: Optional[Dict[str, Any]] = None, call_id: Optional[str] = None, raw_data: Optional[Dict[str, Any]] = None):
231
166
  """
@@ -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()
@@ -42,6 +42,7 @@ except ImportError:
42
42
 
43
43
  from signalwire_agents.utils.schema_utils import SchemaUtils
44
44
  from signalwire_agents.core.swml_handler import VerbHandlerRegistry, SWMLVerbHandler
45
+ from signalwire_agents.core.security_config import SecurityConfig
45
46
 
46
47
 
47
48
  class SWMLService:
@@ -65,7 +66,8 @@ class SWMLService:
65
66
  host: str = "0.0.0.0",
66
67
  port: int = 3000,
67
68
  basic_auth: Optional[Tuple[str, str]] = None,
68
- schema_path: Optional[str] = None
69
+ schema_path: Optional[str] = None,
70
+ config_file: Optional[str] = None
69
71
  ):
70
72
  """
71
73
  Initialize a new SWML service
@@ -77,22 +79,26 @@ class SWMLService:
77
79
  port: Port to bind the web server to
78
80
  basic_auth: Optional (username, password) tuple for basic auth
79
81
  schema_path: Optional path to the schema file
82
+ config_file: Optional path to configuration file
80
83
  """
81
84
  self.name = name
82
85
  self.route = route.rstrip("/") # Ensure no trailing slash
83
86
  self.host = host
84
87
  self.port = port
85
88
 
86
- # Initialize SSL configuration from environment variables
87
- ssl_enabled_env = os.environ.get('SWML_SSL_ENABLED', '').lower()
88
- self.ssl_enabled = ssl_enabled_env in ('true', '1', 'yes')
89
- self.domain = os.environ.get('SWML_DOMAIN')
90
- self.ssl_cert_path = os.environ.get('SWML_SSL_CERT_PATH')
91
- self.ssl_key_path = os.environ.get('SWML_SSL_KEY_PATH')
92
-
93
89
  # Initialize logger for this instance FIRST before using it
94
90
  self.log = logger.bind(service=name)
95
91
 
92
+ # Load unified security configuration with optional config file
93
+ self.security = SecurityConfig(config_file=config_file, service_name=name)
94
+ self.security.log_config("SWMLService")
95
+
96
+ # For backward compatibility, expose SSL settings as instance attributes
97
+ self.ssl_enabled = self.security.ssl_enabled
98
+ self.domain = self.security.domain
99
+ self.ssl_cert_path = self.security.ssl_cert_path
100
+ self.ssl_key_path = self.security.ssl_key_path
101
+
96
102
  # Initialize proxy detection attributes
97
103
  self._proxy_url_base = os.environ.get('SWML_PROXY_URL_BASE')
98
104
  self._proxy_url_base_from_env = bool(self._proxy_url_base) # Track if it came from environment
@@ -108,18 +114,8 @@ class SWMLService:
108
114
  # Use provided credentials
109
115
  self._basic_auth = basic_auth
110
116
  else:
111
- # Check environment variables first
112
- env_user = os.environ.get('SWML_BASIC_AUTH_USER')
113
- env_pass = os.environ.get('SWML_BASIC_AUTH_PASSWORD')
114
-
115
- if env_user and env_pass:
116
- # Use environment variables
117
- self._basic_auth = (env_user, env_pass)
118
- else:
119
- # Generate random credentials as fallback
120
- username = f"user_{secrets.token_hex(4)}"
121
- password = secrets.token_urlsafe(16)
122
- self._basic_auth = (username, password)
117
+ # Use unified security config for auth credentials
118
+ self._basic_auth = self.security.get_basic_auth()
123
119
 
124
120
  # Find the schema file if not provided
125
121
  if schema_path is None:
@@ -768,11 +764,9 @@ class SWMLService:
768
764
 
769
765
  # Validate SSL configuration if enabled
770
766
  if self.ssl_enabled:
771
- if not ssl_cert_path or not os.path.exists(ssl_cert_path):
772
- self.log.warning("ssl_cert_not_found", path=ssl_cert_path)
773
- self.ssl_enabled = False
774
- elif not ssl_key_path or not os.path.exists(ssl_key_path):
775
- self.log.warning("ssl_key_not_found", path=ssl_key_path)
767
+ is_valid, error = self.security.validate_ssl_config()
768
+ if not is_valid:
769
+ self.log.warning("ssl_config_invalid", error=error)
776
770
  self.ssl_enabled = False
777
771
  elif not self.domain:
778
772
  self.log.warning("ssl_domain_not_specified")
@@ -52,7 +52,6 @@ class ConciergeAgent(AgentBase):
52
52
  welcome_message: Optional[str] = None,
53
53
  name: str = "concierge",
54
54
  route: str = "/concierge",
55
- enable_state_tracking: bool = True,
56
55
  **kwargs
57
56
  ):
58
57
  """
@@ -67,7 +66,6 @@ class ConciergeAgent(AgentBase):
67
66
  welcome_message: Optional custom welcome message
68
67
  name: Agent name for the route
69
68
  route: HTTP route for this agent
70
- enable_state_tracking: Whether to enable state tracking (default: True)
71
69
  **kwargs: Additional arguments for AgentBase
72
70
  """
73
71
  # Initialize the base agent
@@ -75,7 +73,6 @@ class ConciergeAgent(AgentBase):
75
73
  name=name,
76
74
  route=route,
77
75
  use_pom=True,
78
- enable_state_tracking=enable_state_tracking,
79
76
  **kwargs
80
77
  )
81
78