asap-protocol 0.3.0__py3-none-any.whl → 1.0.0__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 (62) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/errors.py +167 -0
  4. asap/examples/README.md +81 -10
  5. asap/examples/auth_patterns.py +212 -0
  6. asap/examples/error_recovery.py +248 -0
  7. asap/examples/long_running.py +287 -0
  8. asap/examples/mcp_integration.py +240 -0
  9. asap/examples/multi_step_workflow.py +134 -0
  10. asap/examples/orchestration.py +293 -0
  11. asap/examples/rate_limiting.py +137 -0
  12. asap/examples/run_demo.py +9 -4
  13. asap/examples/secure_handler.py +84 -0
  14. asap/examples/state_migration.py +240 -0
  15. asap/examples/streaming_response.py +108 -0
  16. asap/examples/websocket_concept.py +129 -0
  17. asap/mcp/__init__.py +43 -0
  18. asap/mcp/client.py +224 -0
  19. asap/mcp/protocol.py +179 -0
  20. asap/mcp/server.py +333 -0
  21. asap/mcp/server_runner.py +40 -0
  22. asap/models/__init__.py +4 -0
  23. asap/models/base.py +0 -3
  24. asap/models/constants.py +76 -1
  25. asap/models/entities.py +58 -7
  26. asap/models/envelope.py +14 -1
  27. asap/models/ids.py +8 -4
  28. asap/models/parts.py +33 -3
  29. asap/models/validators.py +16 -0
  30. asap/observability/__init__.py +6 -0
  31. asap/observability/dashboards/README.md +24 -0
  32. asap/observability/dashboards/asap-detailed.json +131 -0
  33. asap/observability/dashboards/asap-red.json +129 -0
  34. asap/observability/logging.py +81 -1
  35. asap/observability/metrics.py +15 -1
  36. asap/observability/trace_parser.py +238 -0
  37. asap/observability/trace_ui.py +218 -0
  38. asap/observability/tracing.py +293 -0
  39. asap/state/machine.py +15 -2
  40. asap/state/snapshot.py +0 -9
  41. asap/testing/__init__.py +31 -0
  42. asap/testing/assertions.py +108 -0
  43. asap/testing/fixtures.py +113 -0
  44. asap/testing/mocks.py +152 -0
  45. asap/transport/__init__.py +31 -0
  46. asap/transport/cache.py +180 -0
  47. asap/transport/circuit_breaker.py +194 -0
  48. asap/transport/client.py +989 -72
  49. asap/transport/compression.py +389 -0
  50. asap/transport/handlers.py +106 -53
  51. asap/transport/middleware.py +64 -39
  52. asap/transport/server.py +461 -94
  53. asap/transport/validators.py +320 -0
  54. asap/utils/__init__.py +7 -0
  55. asap/utils/sanitization.py +134 -0
  56. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  57. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  58. asap_protocol-0.3.0.dist-info/METADATA +0 -227
  59. asap_protocol-0.3.0.dist-info/RECORD +0 -37
  60. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  61. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  62. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,320 @@
1
+ """Validation utilities for ASAP protocol envelopes.
2
+
3
+ This module provides validation functions for envelope security checks,
4
+ including timestamp validation for replay attack prevention and optional
5
+ nonce validation for duplicate detection.
6
+ """
7
+
8
+ import threading
9
+ import time
10
+ from datetime import datetime, timezone
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from asap.errors import InvalidNonceError, InvalidTimestampError
14
+ from asap.models.constants import (
15
+ MAX_ENVELOPE_AGE_SECONDS,
16
+ MAX_FUTURE_TOLERANCE_SECONDS,
17
+ NONCE_TTL_SECONDS,
18
+ )
19
+ from asap.models.envelope import Envelope
20
+
21
+
22
+ def validate_envelope_timestamp(envelope: Envelope) -> None:
23
+ """Validate envelope timestamp to prevent replay attacks.
24
+
25
+ Checks that the envelope timestamp is:
26
+ - Not older than MAX_ENVELOPE_AGE_SECONDS (default: 5 minutes)
27
+ - Not more than MAX_FUTURE_TOLERANCE_SECONDS in the future (default: 30 seconds)
28
+
29
+ Args:
30
+ envelope: The envelope to validate
31
+
32
+ Raises:
33
+ InvalidTimestampError: If the timestamp is too old or too far in the future
34
+
35
+ Example:
36
+ >>> from asap.models.envelope import Envelope
37
+ >>> from asap.transport.validators import validate_envelope_timestamp
38
+ >>>
39
+ >>> # Recent envelope passes
40
+ >>> recent = Envelope(
41
+ ... asap_version="0.1",
42
+ ... sender="urn:asap:agent:test",
43
+ ... recipient="urn:asap:agent:test",
44
+ ... payload_type="TaskRequest",
45
+ ... payload={}
46
+ ... )
47
+ >>> validate_envelope_timestamp(recent) # No exception
48
+ >>>
49
+ >>> # Old envelope raises error
50
+ >>> from datetime import datetime, timezone, timedelta
51
+ >>> old = Envelope(
52
+ ... asap_version="0.1",
53
+ ... sender="urn:asap:agent:test",
54
+ ... recipient="urn:asap:agent:test",
55
+ ... payload_type="TaskRequest",
56
+ ... payload={},
57
+ ... timestamp=datetime.now(timezone.utc) - timedelta(minutes=10)
58
+ ... )
59
+ >>> validate_envelope_timestamp(old) # Raises InvalidTimestampError
60
+ """
61
+ if envelope.timestamp is None:
62
+ raise InvalidTimestampError(
63
+ timestamp="",
64
+ message="Envelope timestamp is required for validation",
65
+ details={"envelope_id": envelope.id},
66
+ )
67
+
68
+ now = datetime.now(timezone.utc)
69
+ timestamp = envelope.timestamp
70
+
71
+ if timestamp.tzinfo is None:
72
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
73
+ else:
74
+ # Convert to UTC if needed
75
+ timestamp = timestamp.astimezone(timezone.utc)
76
+
77
+ # Calculate age in seconds
78
+ age_seconds = (now - timestamp).total_seconds()
79
+
80
+ if age_seconds > MAX_ENVELOPE_AGE_SECONDS:
81
+ raise InvalidTimestampError(
82
+ timestamp=timestamp.isoformat(),
83
+ message=(
84
+ f"Envelope timestamp is too old: {age_seconds:.1f} seconds "
85
+ f"(max: {MAX_ENVELOPE_AGE_SECONDS} seconds)"
86
+ ),
87
+ age_seconds=age_seconds,
88
+ details={
89
+ "envelope_id": envelope.id,
90
+ "max_age_seconds": MAX_ENVELOPE_AGE_SECONDS,
91
+ },
92
+ )
93
+
94
+ future_offset_seconds = (timestamp - now).total_seconds()
95
+ if future_offset_seconds > MAX_FUTURE_TOLERANCE_SECONDS:
96
+ raise InvalidTimestampError(
97
+ timestamp=timestamp.isoformat(),
98
+ message=(
99
+ f"Envelope timestamp is too far in the future: "
100
+ f"{future_offset_seconds:.1f} seconds "
101
+ f"(max tolerance: {MAX_FUTURE_TOLERANCE_SECONDS} seconds)"
102
+ ),
103
+ future_offset_seconds=future_offset_seconds,
104
+ details={
105
+ "envelope_id": envelope.id,
106
+ "max_future_tolerance_seconds": MAX_FUTURE_TOLERANCE_SECONDS,
107
+ },
108
+ )
109
+
110
+
111
+ @runtime_checkable
112
+ class NonceStore(Protocol):
113
+ """Protocol for nonce storage and validation.
114
+
115
+ Implementations must provide thread-safe storage and TTL-based expiration
116
+ for nonce values to prevent replay attacks.
117
+ """
118
+
119
+ def is_used(self, nonce: str) -> bool:
120
+ """Check if a nonce has been used.
121
+
122
+ Args:
123
+ nonce: The nonce value to check
124
+
125
+ Returns:
126
+ True if the nonce has been used, False otherwise
127
+ """
128
+ ...
129
+
130
+ def mark_used(self, nonce: str, ttl_seconds: int) -> None:
131
+ """Mark a nonce as used with a TTL.
132
+
133
+ Args:
134
+ nonce: The nonce value to mark as used
135
+ ttl_seconds: Time-to-live in seconds for the nonce
136
+ """
137
+ ...
138
+
139
+ def check_and_mark(self, nonce: str, ttl_seconds: int) -> bool:
140
+ """Atomically check if nonce is used and mark it if not.
141
+
142
+ This method performs both check and mark operations atomically to
143
+ prevent race conditions between concurrent requests.
144
+
145
+ Args:
146
+ nonce: The nonce value to check and mark
147
+ ttl_seconds: Time-to-live in seconds for the nonce
148
+
149
+ Returns:
150
+ True if nonce was already used, False if it was newly marked
151
+ """
152
+ ...
153
+
154
+
155
+ class InMemoryNonceStore:
156
+ """In-memory nonce store with TTL-based expiration.
157
+
158
+ This implementation uses a dictionary to store nonces with their
159
+ expiration times. Expired nonces are lazily cleaned up on access.
160
+
161
+ Attributes:
162
+ _store: Dictionary mapping nonce to expiration timestamp
163
+ _lock: Thread lock for thread-safe operations
164
+ """
165
+
166
+ def __init__(self) -> None:
167
+ """Initialize in-memory nonce store."""
168
+ self._store: dict[str, float] = {}
169
+ self._lock = threading.RLock()
170
+
171
+ def _cleanup_expired(self) -> None:
172
+ """Remove expired nonces from the store.
173
+
174
+ This is called lazily during access operations to keep the store
175
+ from growing unbounded.
176
+ """
177
+ now = time.time()
178
+ expired = [nonce for nonce, expiry in self._store.items() if expiry < now]
179
+ for nonce in expired:
180
+ self._store.pop(nonce, None)
181
+
182
+ def is_used(self, nonce: str) -> bool:
183
+ """Check if a nonce has been used.
184
+
185
+ Args:
186
+ nonce: The nonce value to check
187
+
188
+ Returns:
189
+ True if the nonce has been used and not expired, False otherwise
190
+ """
191
+ with self._lock:
192
+ self._cleanup_expired()
193
+ if nonce not in self._store:
194
+ return False
195
+ expiry = self._store[nonce]
196
+ return expiry >= time.time()
197
+
198
+ def mark_used(self, nonce: str, ttl_seconds: int) -> None:
199
+ """Mark a nonce as used with a TTL.
200
+
201
+ Args:
202
+ nonce: The nonce value to mark as used
203
+ ttl_seconds: Time-to-live in seconds for the nonce
204
+ """
205
+ with self._lock:
206
+ self._cleanup_expired()
207
+ expiry = time.time() + ttl_seconds
208
+ self._store[nonce] = expiry
209
+
210
+ def check_and_mark(self, nonce: str, ttl_seconds: int) -> bool:
211
+ """Atomically check if nonce is used and mark it if not.
212
+
213
+ This method performs both check and mark operations atomically to
214
+ prevent race conditions between concurrent requests with the same nonce.
215
+
216
+ Args:
217
+ nonce: The nonce value to check and mark
218
+ ttl_seconds: Time-to-live in seconds for the nonce
219
+
220
+ Returns:
221
+ True if nonce was already used, False if it was newly marked
222
+ """
223
+ with self._lock:
224
+ self._cleanup_expired()
225
+ if nonce in self._store and self._store[nonce] >= time.time():
226
+ return True # Already used
227
+ self._store[nonce] = time.time() + ttl_seconds
228
+ return False # Newly marked
229
+
230
+
231
+ def validate_envelope_nonce(envelope: Envelope, nonce_store: NonceStore | None) -> None:
232
+ """Validate envelope nonce to prevent duplicate message replay.
233
+
234
+ If the envelope has a nonce in its extensions, checks that it hasn't
235
+ been used before. If no nonce is present, validation passes (nonce
236
+ is optional). If a nonce_store is not provided, validation is skipped.
237
+
238
+ Args:
239
+ envelope: The envelope to validate
240
+ nonce_store: Optional nonce store for duplicate detection
241
+
242
+ Raises:
243
+ InvalidNonceError: If the nonce has been used before
244
+
245
+ Example:
246
+ >>> from asap.models.envelope import Envelope
247
+ >>> from asap.transport.validators import (
248
+ ... validate_envelope_nonce,
249
+ ... InMemoryNonceStore
250
+ ... )
251
+ >>>
252
+ >>> store = InMemoryNonceStore()
253
+ >>>
254
+ >>> # Envelope without nonce passes
255
+ >>> envelope1 = Envelope(
256
+ ... asap_version="0.1",
257
+ ... sender="urn:asap:agent:test",
258
+ ... recipient="urn:asap:agent:test",
259
+ ... payload_type="TaskRequest",
260
+ ... payload={}
261
+ ... )
262
+ >>> validate_envelope_nonce(envelope1, store) # No exception
263
+ >>>
264
+ >>> # First use of nonce passes
265
+ >>> envelope2 = Envelope(
266
+ ... asap_version="0.1",
267
+ ... sender="urn:asap:agent:test",
268
+ ... recipient="urn:asap:agent:test",
269
+ ... payload_type="TaskRequest",
270
+ ... payload={},
271
+ ... extensions={"nonce": "unique-nonce-123"}
272
+ ... )
273
+ >>> validate_envelope_nonce(envelope2, store) # No exception
274
+ >>>
275
+ >>> # Duplicate nonce raises error
276
+ >>> envelope3 = Envelope(
277
+ ... asap_version="0.1",
278
+ ... sender="urn:asap:agent:test",
279
+ ... recipient="urn:asap:agent:test",
280
+ ... payload_type="TaskRequest",
281
+ ... payload={},
282
+ ... extensions={"nonce": "unique-nonce-123"}
283
+ ... )
284
+ >>> validate_envelope_nonce(envelope3, store) # Raises InvalidNonceError
285
+ """
286
+ # Skip validation if no nonce store provided
287
+ if nonce_store is None:
288
+ return
289
+
290
+ # Skip validation if no nonce in extensions
291
+ if not envelope.extensions or "nonce" not in envelope.extensions:
292
+ return
293
+
294
+ nonce = envelope.extensions["nonce"]
295
+
296
+ if not isinstance(nonce, str) or not nonce:
297
+ raise InvalidNonceError(
298
+ nonce=str(nonce),
299
+ message=(
300
+ f"Nonce must be a non-empty string, got "
301
+ f"{type(nonce).__name__ if not isinstance(nonce, str) else 'empty string'}"
302
+ ),
303
+ details={"envelope_id": envelope.id},
304
+ )
305
+
306
+ # Atomically check and mark nonce as used to prevent race conditions
307
+ if nonce_store.check_and_mark(nonce, ttl_seconds=NONCE_TTL_SECONDS):
308
+ raise InvalidNonceError(
309
+ nonce=nonce,
310
+ message=f"Duplicate nonce detected: {nonce}",
311
+ details={"envelope_id": envelope.id},
312
+ )
313
+
314
+
315
+ __all__ = [
316
+ "NonceStore",
317
+ "InMemoryNonceStore",
318
+ "validate_envelope_timestamp",
319
+ "validate_envelope_nonce",
320
+ ]
asap/utils/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Utility modules for ASAP protocol.
2
+
3
+ This package contains utility functions used across the ASAP protocol
4
+ implementation, including security utilities like log sanitization.
5
+ """
6
+
7
+ __all__: list[str] = []
@@ -0,0 +1,134 @@
1
+ """Log sanitization utilities for sensitive data protection.
2
+
3
+ This module provides functions to sanitize sensitive data before logging
4
+ to prevent credential leaks, token exposure, and other security issues.
5
+
6
+ Example:
7
+ >>> from asap.utils.sanitization import sanitize_token, sanitize_nonce, sanitize_url
8
+ >>>
9
+ >>> # Sanitize a token (shows only first 8 characters)
10
+ >>> token = "sk_live_1234567890abcdef"
11
+ >>> sanitize_token(token) # Returns: "sk_live_..."
12
+ >>>
13
+ >>> # Sanitize a nonce (shows first 8 characters + "...")
14
+ >>> nonce = "a1b2c3d4e5f6g7h8i9j0"
15
+ >>> sanitize_nonce(nonce) # Returns: "a1b2c3d4..."
16
+ >>>
17
+ >>> # Sanitize a URL with credentials
18
+ >>> url = "https://user:password@example.com/api"
19
+ >>> sanitize_url(url) # Returns: "https://user:***@example.com/api"
20
+ """
21
+
22
+ import re
23
+ from urllib.parse import urlparse, urlunparse
24
+
25
+ # Sanitization configuration
26
+ SANITIZE_PREFIX_LENGTH = 8
27
+ """Number of characters to show when truncating sensitive values.
28
+
29
+ This constant defines how many characters of a sensitive value (token, nonce)
30
+ are preserved when sanitizing for logs. The value balances security (preventing
31
+ full exposure) with debuggability (allowing identification of value types).
32
+ """
33
+
34
+
35
+ def sanitize_token(token: str) -> str:
36
+ """Sanitize a token for safe logging.
37
+
38
+ Returns only the first SANITIZE_PREFIX_LENGTH characters followed by "..."
39
+ to prevent full token exposure in logs while still allowing identification
40
+ of token type (e.g., "sk_live_", "pk_test_").
41
+
42
+ Args:
43
+ token: The token to sanitize
44
+
45
+ Returns:
46
+ Sanitized token string showing only prefix (e.g., "sk_live_...")
47
+
48
+ Example:
49
+ >>> sanitize_token("sk_live_1234567890abcdef")
50
+ 'sk_live_...'
51
+ >>> sanitize_token("short")
52
+ 'short'
53
+ """
54
+ if not token:
55
+ return ""
56
+ if len(token) <= SANITIZE_PREFIX_LENGTH:
57
+ return token
58
+ return f"{token[:SANITIZE_PREFIX_LENGTH]}..."
59
+
60
+
61
+ def sanitize_nonce(nonce: str) -> str:
62
+ """Sanitize a nonce for safe logging.
63
+
64
+ Returns the first SANITIZE_PREFIX_LENGTH characters followed by "..."
65
+ to prevent full nonce exposure in logs while still allowing identification
66
+ for debugging purposes.
67
+
68
+ Args:
69
+ nonce: The nonce to sanitize
70
+
71
+ Returns:
72
+ Sanitized nonce string showing first characters + "..." (e.g., "a1b2c3d4...")
73
+
74
+ Example:
75
+ >>> sanitize_nonce("a1b2c3d4e5f6g7h8i9j0")
76
+ 'a1b2c3d4...'
77
+ >>> sanitize_nonce("short")
78
+ 'short'
79
+ """
80
+ if not nonce:
81
+ return ""
82
+ if len(nonce) <= SANITIZE_PREFIX_LENGTH:
83
+ return nonce
84
+ return f"{nonce[:SANITIZE_PREFIX_LENGTH]}..."
85
+
86
+
87
+ def sanitize_url(url: str) -> str:
88
+ """Sanitize a URL by masking credentials in the userinfo component.
89
+
90
+ Masks passwords in URLs of the form:
91
+ - `https://user:password@example.com` → `https://user:***@example.com`
92
+ - `https://user@example.com` → `https://user@example.com` (no change)
93
+
94
+ Args:
95
+ url: The URL to sanitize
96
+
97
+ Returns:
98
+ Sanitized URL with masked credentials
99
+
100
+ Example:
101
+ >>> sanitize_url("https://user:secret@example.com/api")
102
+ 'https://user:***@example.com/api'
103
+ >>> sanitize_url("https://example.com/api")
104
+ 'https://example.com/api'
105
+ """
106
+ if not url:
107
+ return ""
108
+
109
+ try:
110
+ parsed = urlparse(url)
111
+ if (parsed.username or parsed.password) and parsed.password:
112
+ netloc = f"{parsed.username}:***@{parsed.hostname}"
113
+ if parsed.port:
114
+ netloc = f"{netloc}:{parsed.port}"
115
+ return urlunparse(
116
+ (
117
+ parsed.scheme,
118
+ netloc,
119
+ parsed.path,
120
+ parsed.params,
121
+ parsed.query,
122
+ parsed.fragment,
123
+ )
124
+ )
125
+ return url
126
+ except Exception:
127
+ return re.sub(r"://[^:]+:[^@]+@", r"://***:***@", url)
128
+
129
+
130
+ __all__ = [
131
+ "sanitize_token",
132
+ "sanitize_nonce",
133
+ "sanitize_url",
134
+ ]