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.
- signalwire_agents/__init__.py +1 -4
- signalwire_agents/cli/config.py +11 -1
- signalwire_agents/cli/simulation/data_overrides.py +6 -2
- signalwire_agents/cli/test_swaig.py +6 -0
- signalwire_agents/core/agent_base.py +1 -12
- signalwire_agents/core/auth_handler.py +233 -0
- signalwire_agents/core/config_loader.py +259 -0
- signalwire_agents/core/contexts.py +75 -0
- signalwire_agents/core/mixins/state_mixin.py +1 -67
- signalwire_agents/core/mixins/tool_mixin.py +0 -65
- signalwire_agents/core/security_config.py +333 -0
- signalwire_agents/core/swml_service.py +19 -25
- signalwire_agents/prefabs/concierge.py +0 -3
- signalwire_agents/prefabs/faq_bot.py +0 -3
- signalwire_agents/prefabs/info_gatherer.py +0 -3
- signalwire_agents/prefabs/receptionist.py +0 -3
- signalwire_agents/prefabs/survey.py +0 -3
- signalwire_agents/search/search_service.py +200 -11
- signalwire_agents/skills/mcp_gateway/README.md +230 -0
- signalwire_agents/skills/mcp_gateway/__init__.py +1 -0
- signalwire_agents/skills/mcp_gateway/skill.py +339 -0
- {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/METADATA +1 -59
- {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/RECORD +27 -24
- signalwire_agents/core/state/__init__.py +0 -17
- signalwire_agents/core/state/file_state_manager.py +0 -219
- signalwire_agents/core/state/state_manager.py +0 -101
- {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/WHEEL +0 -0
- {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/entry_points.txt +0 -0
- {signalwire_agents-0.1.27.dist-info → signalwire_agents-0.1.29.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
#
|
112
|
-
|
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
|
-
|
772
|
-
|
773
|
-
self.
|
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
|
|