signalwire-agents 0.1.6__py3-none-any.whl → 1.0.7__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 +130 -4
- signalwire_agents/agent_server.py +438 -32
- signalwire_agents/agents/bedrock.py +296 -0
- signalwire_agents/cli/__init__.py +18 -0
- signalwire_agents/cli/build_search.py +1367 -0
- signalwire_agents/cli/config.py +80 -0
- signalwire_agents/cli/core/__init__.py +10 -0
- signalwire_agents/cli/core/agent_loader.py +470 -0
- signalwire_agents/cli/core/argparse_helpers.py +179 -0
- signalwire_agents/cli/core/dynamic_config.py +71 -0
- signalwire_agents/cli/core/service_loader.py +303 -0
- signalwire_agents/cli/execution/__init__.py +10 -0
- signalwire_agents/cli/execution/datamap_exec.py +446 -0
- signalwire_agents/cli/execution/webhook_exec.py +134 -0
- signalwire_agents/cli/init_project.py +1225 -0
- signalwire_agents/cli/output/__init__.py +10 -0
- signalwire_agents/cli/output/output_formatter.py +255 -0
- signalwire_agents/cli/output/swml_dump.py +186 -0
- signalwire_agents/cli/simulation/__init__.py +10 -0
- signalwire_agents/cli/simulation/data_generation.py +374 -0
- signalwire_agents/cli/simulation/data_overrides.py +200 -0
- signalwire_agents/cli/simulation/mock_env.py +282 -0
- signalwire_agents/cli/swaig_test_wrapper.py +52 -0
- signalwire_agents/cli/test_swaig.py +809 -0
- signalwire_agents/cli/types.py +81 -0
- signalwire_agents/core/__init__.py +2 -2
- signalwire_agents/core/agent/__init__.py +12 -0
- signalwire_agents/core/agent/config/__init__.py +12 -0
- signalwire_agents/core/agent/deployment/__init__.py +9 -0
- signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
- signalwire_agents/core/agent/prompt/__init__.py +14 -0
- signalwire_agents/core/agent/prompt/manager.py +306 -0
- signalwire_agents/core/agent/routing/__init__.py +9 -0
- signalwire_agents/core/agent/security/__init__.py +9 -0
- signalwire_agents/core/agent/swml/__init__.py +9 -0
- signalwire_agents/core/agent/tools/__init__.py +15 -0
- signalwire_agents/core/agent/tools/decorator.py +97 -0
- signalwire_agents/core/agent/tools/registry.py +210 -0
- signalwire_agents/core/agent_base.py +959 -2166
- signalwire_agents/core/auth_handler.py +233 -0
- signalwire_agents/core/config_loader.py +259 -0
- signalwire_agents/core/contexts.py +707 -0
- signalwire_agents/core/data_map.py +487 -0
- signalwire_agents/core/function_result.py +1150 -1
- signalwire_agents/core/logging_config.py +376 -0
- signalwire_agents/core/mixins/__init__.py +28 -0
- signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
- signalwire_agents/core/mixins/auth_mixin.py +287 -0
- signalwire_agents/core/mixins/prompt_mixin.py +358 -0
- signalwire_agents/core/mixins/serverless_mixin.py +368 -0
- signalwire_agents/core/mixins/skill_mixin.py +55 -0
- signalwire_agents/core/mixins/state_mixin.py +153 -0
- signalwire_agents/core/mixins/tool_mixin.py +230 -0
- signalwire_agents/core/mixins/web_mixin.py +1134 -0
- signalwire_agents/core/security/session_manager.py +174 -86
- signalwire_agents/core/security_config.py +333 -0
- signalwire_agents/core/skill_base.py +200 -0
- signalwire_agents/core/skill_manager.py +244 -0
- signalwire_agents/core/swaig_function.py +33 -9
- signalwire_agents/core/swml_builder.py +212 -12
- signalwire_agents/core/swml_handler.py +43 -13
- signalwire_agents/core/swml_renderer.py +123 -297
- signalwire_agents/core/swml_service.py +277 -260
- signalwire_agents/prefabs/concierge.py +6 -2
- signalwire_agents/prefabs/info_gatherer.py +149 -33
- signalwire_agents/prefabs/receptionist.py +14 -22
- signalwire_agents/prefabs/survey.py +6 -2
- signalwire_agents/schema.json +9218 -5489
- signalwire_agents/search/__init__.py +137 -0
- signalwire_agents/search/document_processor.py +1223 -0
- signalwire_agents/search/index_builder.py +804 -0
- signalwire_agents/search/migration.py +418 -0
- signalwire_agents/search/models.py +30 -0
- signalwire_agents/search/pgvector_backend.py +752 -0
- signalwire_agents/search/query_processor.py +502 -0
- signalwire_agents/search/search_engine.py +1264 -0
- signalwire_agents/search/search_service.py +574 -0
- signalwire_agents/skills/README.md +452 -0
- signalwire_agents/skills/__init__.py +23 -0
- signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
- signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
- signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
- signalwire_agents/skills/datasphere/README.md +210 -0
- signalwire_agents/skills/datasphere/__init__.py +12 -0
- signalwire_agents/skills/datasphere/skill.py +310 -0
- signalwire_agents/skills/datasphere_serverless/README.md +258 -0
- signalwire_agents/skills/datasphere_serverless/__init__.py +10 -0
- signalwire_agents/skills/datasphere_serverless/skill.py +237 -0
- signalwire_agents/skills/datetime/README.md +132 -0
- signalwire_agents/skills/datetime/__init__.py +10 -0
- signalwire_agents/skills/datetime/skill.py +126 -0
- signalwire_agents/skills/joke/README.md +149 -0
- signalwire_agents/skills/joke/__init__.py +10 -0
- signalwire_agents/skills/joke/skill.py +109 -0
- signalwire_agents/skills/math/README.md +161 -0
- signalwire_agents/skills/math/__init__.py +10 -0
- signalwire_agents/skills/math/skill.py +105 -0
- signalwire_agents/skills/mcp_gateway/README.md +230 -0
- signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
- signalwire_agents/skills/mcp_gateway/skill.py +421 -0
- signalwire_agents/skills/native_vector_search/README.md +210 -0
- signalwire_agents/skills/native_vector_search/__init__.py +10 -0
- signalwire_agents/skills/native_vector_search/skill.py +820 -0
- signalwire_agents/skills/play_background_file/README.md +218 -0
- signalwire_agents/skills/play_background_file/__init__.py +12 -0
- signalwire_agents/skills/play_background_file/skill.py +242 -0
- signalwire_agents/skills/registry.py +459 -0
- signalwire_agents/skills/spider/README.md +236 -0
- signalwire_agents/skills/spider/__init__.py +13 -0
- signalwire_agents/skills/spider/skill.py +598 -0
- signalwire_agents/skills/swml_transfer/README.md +395 -0
- signalwire_agents/skills/swml_transfer/__init__.py +10 -0
- signalwire_agents/skills/swml_transfer/skill.py +359 -0
- signalwire_agents/skills/weather_api/README.md +178 -0
- signalwire_agents/skills/weather_api/__init__.py +12 -0
- signalwire_agents/skills/weather_api/skill.py +191 -0
- signalwire_agents/skills/web_search/README.md +163 -0
- signalwire_agents/skills/web_search/__init__.py +10 -0
- signalwire_agents/skills/web_search/skill.py +739 -0
- signalwire_agents/skills/wikipedia_search/README.md +228 -0
- signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
- signalwire_agents/skills/wikipedia_search/skill.py +210 -0
- signalwire_agents/utils/__init__.py +14 -0
- signalwire_agents/utils/schema_utils.py +111 -44
- signalwire_agents/web/__init__.py +17 -0
- signalwire_agents/web/web_service.py +559 -0
- signalwire_agents-1.0.7.data/data/share/man/man1/sw-agent-init.1 +307 -0
- signalwire_agents-1.0.7.data/data/share/man/man1/sw-search.1 +483 -0
- signalwire_agents-1.0.7.data/data/share/man/man1/swaig-test.1 +308 -0
- signalwire_agents-1.0.7.dist-info/METADATA +992 -0
- signalwire_agents-1.0.7.dist-info/RECORD +142 -0
- {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/WHEEL +1 -1
- signalwire_agents-1.0.7.dist-info/entry_points.txt +4 -0
- signalwire_agents/core/state/file_state_manager.py +0 -219
- signalwire_agents/core/state/state_manager.py +0 -101
- signalwire_agents-0.1.6.data/data/schema.json +0 -5611
- signalwire_agents-0.1.6.dist-info/METADATA +0 -199
- signalwire_agents-0.1.6.dist-info/RECORD +0 -34
- {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/licenses/LICENSE +0 -0
- {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/top_level.txt +0 -0
|
@@ -14,73 +14,92 @@ Session manager for handling call sessions and security tokens
|
|
|
14
14
|
from typing import Dict, Any, Optional, Tuple
|
|
15
15
|
import secrets
|
|
16
16
|
import time
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"""
|
|
22
|
-
Represents a single call session with associated tokens and state
|
|
23
|
-
"""
|
|
24
|
-
def __init__(self, call_id: str):
|
|
25
|
-
self.call_id = call_id
|
|
26
|
-
self.tokens: Dict[str, str] = {} # function_name -> token
|
|
27
|
-
self.state = "pending" # pending, active, expired
|
|
28
|
-
self.started_at = datetime.now()
|
|
29
|
-
self.metadata: Dict[str, Any] = {} # Custom state for the call
|
|
17
|
+
import hmac
|
|
18
|
+
import hashlib
|
|
19
|
+
import base64
|
|
20
|
+
from datetime import datetime, timedelta
|
|
30
21
|
|
|
31
22
|
|
|
32
23
|
class SessionManager:
|
|
33
24
|
"""
|
|
34
|
-
Manages
|
|
25
|
+
Manages security tokens for function calls
|
|
26
|
+
|
|
27
|
+
This implementation is completely stateless - it does not track call sessions
|
|
28
|
+
or store any information in memory. All validation is done using cryptographic
|
|
29
|
+
signatures with the tokens containing all necessary information.
|
|
35
30
|
"""
|
|
36
|
-
def __init__(self, token_expiry_secs: int =
|
|
31
|
+
def __init__(self, token_expiry_secs: int = 3600, secret_key: Optional[str] = None):
|
|
37
32
|
"""
|
|
38
33
|
Initialize the session manager
|
|
39
34
|
|
|
40
35
|
Args:
|
|
41
|
-
token_expiry_secs: Seconds until tokens expire (default:
|
|
36
|
+
token_expiry_secs: Seconds until tokens expire (default: 60 minutes)
|
|
37
|
+
secret_key: Secret key for signing tokens (generated if not provided)
|
|
42
38
|
"""
|
|
43
|
-
self._active_calls: Dict[str, CallSession] = {}
|
|
44
39
|
self.token_expiry_secs = token_expiry_secs
|
|
40
|
+
# Use provided secret key or generate a secure one
|
|
41
|
+
self.secret_key = secret_key or secrets.token_hex(32)
|
|
45
42
|
|
|
46
43
|
def create_session(self, call_id: Optional[str] = None) -> str:
|
|
47
44
|
"""
|
|
48
|
-
Create a new
|
|
45
|
+
Create a new session ID if one isn't provided
|
|
49
46
|
|
|
50
47
|
Args:
|
|
51
48
|
call_id: Optional call ID, generated if not provided
|
|
52
49
|
|
|
53
50
|
Returns:
|
|
54
|
-
The call_id for the
|
|
51
|
+
The call_id for the session
|
|
55
52
|
"""
|
|
56
53
|
# Generate call_id if not provided
|
|
57
54
|
if not call_id:
|
|
58
55
|
call_id = secrets.token_urlsafe(16)
|
|
59
56
|
|
|
60
|
-
# Create new session
|
|
61
|
-
self._active_calls[call_id] = CallSession(call_id)
|
|
62
57
|
return call_id
|
|
63
58
|
|
|
64
59
|
def generate_token(self, function_name: str, call_id: str) -> str:
|
|
65
60
|
"""
|
|
66
|
-
Generate a secure token for a function call
|
|
61
|
+
Generate a secure self-contained token for a function call
|
|
67
62
|
|
|
68
63
|
Args:
|
|
69
64
|
function_name: Name of the function to generate a token for
|
|
70
65
|
call_id: Call session ID
|
|
71
66
|
|
|
72
67
|
Returns:
|
|
73
|
-
A secure
|
|
74
|
-
|
|
75
|
-
Raises:
|
|
76
|
-
ValueError: If the call session does not exist
|
|
68
|
+
A secure token
|
|
77
69
|
"""
|
|
78
|
-
|
|
79
|
-
|
|
70
|
+
# Create token parts
|
|
71
|
+
expiry = int(time.time()) + self.token_expiry_secs
|
|
72
|
+
nonce = secrets.token_hex(4)
|
|
73
|
+
|
|
74
|
+
# Create the message to sign
|
|
75
|
+
message = f"{call_id}:{function_name}:{expiry}:{nonce}"
|
|
76
|
+
|
|
77
|
+
# Sign the message
|
|
78
|
+
signature = hmac.new(
|
|
79
|
+
self.secret_key.encode(),
|
|
80
|
+
message.encode(),
|
|
81
|
+
hashlib.sha256
|
|
82
|
+
).hexdigest()[:16] # Use first 16 chars of signature for shorter tokens
|
|
80
83
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
# Combine all parts into the token
|
|
85
|
+
token = f"{call_id}.{function_name}.{expiry}.{nonce}.{signature}"
|
|
86
|
+
|
|
87
|
+
# Base64 encode for URL safety
|
|
88
|
+
return base64.urlsafe_b64encode(token.encode()).decode()
|
|
89
|
+
|
|
90
|
+
# Alias for generate_token to maintain backward compatibility
|
|
91
|
+
def create_tool_token(self, function_name: str, call_id: str) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Alias for generate_token to maintain backward compatibility
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
function_name: Name of the function to generate a token for
|
|
97
|
+
call_id: Call session ID
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A secure token
|
|
101
|
+
"""
|
|
102
|
+
return self.generate_token(function_name, call_id)
|
|
84
103
|
|
|
85
104
|
def validate_token(self, call_id: str, function_name: str, token: str) -> bool:
|
|
86
105
|
"""
|
|
@@ -94,86 +113,155 @@ class SessionManager:
|
|
|
94
113
|
Returns:
|
|
95
114
|
True if valid, False otherwise
|
|
96
115
|
"""
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# Check if token matches and is not expired
|
|
102
|
-
expected_token = session.tokens.get(function_name)
|
|
103
|
-
if not expected_token or expected_token != token:
|
|
104
|
-
return False
|
|
116
|
+
try:
|
|
117
|
+
# Decode the token
|
|
118
|
+
decoded_token = base64.urlsafe_b64decode(token.encode()).decode()
|
|
105
119
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
# Split the token parts
|
|
121
|
+
parts = decoded_token.split('.')
|
|
122
|
+
if len(parts) != 5:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
token_call_id, token_function, token_expiry, token_nonce, token_signature = parts
|
|
112
126
|
|
|
113
|
-
|
|
127
|
+
# Special case: if call_id is None or empty, use the call_id from the token
|
|
128
|
+
# This helps with scenarios where the call_id isn't provided in the request
|
|
129
|
+
if not call_id:
|
|
130
|
+
call_id = token_call_id
|
|
131
|
+
|
|
132
|
+
# Verify the function matches
|
|
133
|
+
if token_function != function_name:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
# Check if the token has expired
|
|
137
|
+
expiry = int(token_expiry)
|
|
138
|
+
if expiry < time.time():
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
# Recreate the message and verify the signature
|
|
142
|
+
message = f"{token_call_id}:{token_function}:{token_expiry}:{token_nonce}"
|
|
143
|
+
expected_signature = hmac.new(
|
|
144
|
+
self.secret_key.encode(),
|
|
145
|
+
message.encode(),
|
|
146
|
+
hashlib.sha256
|
|
147
|
+
).hexdigest()[:16]
|
|
148
|
+
|
|
149
|
+
if token_signature != expected_signature:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# Finally, verify the call_id matches unless we're in special case
|
|
153
|
+
# This check is done last to ensure the token is otherwise valid
|
|
154
|
+
if token_call_id != call_id:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
return True
|
|
158
|
+
except Exception:
|
|
159
|
+
# Any exception during validation means the token is invalid
|
|
160
|
+
return False
|
|
114
161
|
|
|
115
|
-
|
|
162
|
+
# Alias for validate_token to maintain backward compatibility
|
|
163
|
+
def validate_tool_token(self, function_name: str, token: str, call_id: str) -> bool:
|
|
116
164
|
"""
|
|
117
|
-
|
|
165
|
+
Alias for validate_token to maintain backward compatibility
|
|
118
166
|
|
|
119
167
|
Args:
|
|
168
|
+
function_name: Name of the function being called
|
|
169
|
+
token: Token to validate
|
|
120
170
|
call_id: Call session ID
|
|
121
171
|
|
|
122
172
|
Returns:
|
|
123
|
-
True if
|
|
173
|
+
True if valid, False otherwise
|
|
174
|
+
"""
|
|
175
|
+
# Reorder parameters to match validate_token signature (call_id first, then function_name)
|
|
176
|
+
return self.validate_token(call_id=call_id, function_name=function_name, token=token)
|
|
177
|
+
|
|
178
|
+
# Legacy methods that now don't track state but provide API compatibility
|
|
179
|
+
|
|
180
|
+
def activate_session(self, call_id: str) -> bool:
|
|
181
|
+
"""
|
|
182
|
+
Legacy method, does nothing but returns success
|
|
124
183
|
"""
|
|
125
|
-
session = self._active_calls.get(call_id)
|
|
126
|
-
if not session:
|
|
127
|
-
return False
|
|
128
|
-
|
|
129
|
-
session.state = "active"
|
|
130
184
|
return True
|
|
131
185
|
|
|
132
186
|
def end_session(self, call_id: str) -> bool:
|
|
133
187
|
"""
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
Args:
|
|
137
|
-
call_id: Call session ID
|
|
138
|
-
|
|
139
|
-
Returns:
|
|
140
|
-
True if successful, False otherwise
|
|
188
|
+
Legacy method, does nothing but returns success
|
|
141
189
|
"""
|
|
142
|
-
|
|
143
|
-
del self._active_calls[call_id]
|
|
144
|
-
return True
|
|
145
|
-
return False
|
|
190
|
+
return True
|
|
146
191
|
|
|
147
192
|
def get_session_metadata(self, call_id: str) -> Optional[Dict[str, Any]]:
|
|
148
193
|
"""
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
Args:
|
|
152
|
-
call_id: Call session ID
|
|
153
|
-
|
|
154
|
-
Returns:
|
|
155
|
-
Metadata dict or None if session not found
|
|
194
|
+
Legacy method, always returns empty metadata
|
|
156
195
|
"""
|
|
157
|
-
|
|
158
|
-
if not session:
|
|
159
|
-
return None
|
|
160
|
-
return session.metadata
|
|
196
|
+
return {}
|
|
161
197
|
|
|
162
198
|
def set_session_metadata(self, call_id: str, key: str, value: Any) -> bool:
|
|
163
199
|
"""
|
|
164
|
-
|
|
200
|
+
Legacy method, does nothing but returns success
|
|
201
|
+
"""
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
def debug_token(self, token: str) -> Dict[str, Any]:
|
|
205
|
+
"""
|
|
206
|
+
Debug a token without validating it
|
|
207
|
+
|
|
208
|
+
This method decodes the token and extracts its components for debugging purposes
|
|
209
|
+
without performing validation.
|
|
165
210
|
|
|
166
211
|
Args:
|
|
167
|
-
|
|
168
|
-
key: Metadata key
|
|
169
|
-
value: Metadata value
|
|
212
|
+
token: The token to debug
|
|
170
213
|
|
|
171
214
|
Returns:
|
|
172
|
-
|
|
215
|
+
Dictionary with token components and analysis
|
|
173
216
|
"""
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
217
|
+
try:
|
|
218
|
+
# Decode the token
|
|
219
|
+
decoded_token = base64.urlsafe_b64decode(token.encode()).decode()
|
|
177
220
|
|
|
178
|
-
|
|
179
|
-
|
|
221
|
+
# Split the token parts
|
|
222
|
+
parts = decoded_token.split('.')
|
|
223
|
+
if len(parts) != 5:
|
|
224
|
+
return {
|
|
225
|
+
"valid_format": False,
|
|
226
|
+
"parts_count": len(parts),
|
|
227
|
+
"decoded": decoded_token
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
token_call_id, token_function, token_expiry, token_nonce, token_signature = parts
|
|
231
|
+
|
|
232
|
+
# Check expiration
|
|
233
|
+
current_time = int(time.time())
|
|
234
|
+
try:
|
|
235
|
+
expiry = int(token_expiry)
|
|
236
|
+
is_expired = expiry < current_time
|
|
237
|
+
expires_in = expiry - current_time if not is_expired else 0
|
|
238
|
+
expiry_date = datetime.fromtimestamp(expiry).isoformat()
|
|
239
|
+
except ValueError:
|
|
240
|
+
expiry = None
|
|
241
|
+
is_expired = None
|
|
242
|
+
expires_in = None
|
|
243
|
+
expiry_date = None
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
"valid_format": True,
|
|
247
|
+
"components": {
|
|
248
|
+
"call_id": token_call_id,
|
|
249
|
+
"function": token_function,
|
|
250
|
+
"expiry": token_expiry,
|
|
251
|
+
"expiry_date": expiry_date,
|
|
252
|
+
"nonce": token_nonce,
|
|
253
|
+
"signature": token_signature
|
|
254
|
+
},
|
|
255
|
+
"status": {
|
|
256
|
+
"current_time": current_time,
|
|
257
|
+
"is_expired": is_expired,
|
|
258
|
+
"expires_in_seconds": expires_in
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
except Exception as e:
|
|
262
|
+
# Any exception during parsing
|
|
263
|
+
return {
|
|
264
|
+
"valid_format": False,
|
|
265
|
+
"error": str(e),
|
|
266
|
+
"token": token
|
|
267
|
+
}
|
|
@@ -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()
|