capiscio-sdk 0.2.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.
@@ -0,0 +1,360 @@
1
+ """Message validation logic."""
2
+ from typing import TYPE_CHECKING, Any, Dict, List
3
+ from ..types import ValidationResult, ValidationIssue, ValidationSeverity
4
+ from ..scoring import TrustScorer, AvailabilityScorer
5
+
6
+ if TYPE_CHECKING:
7
+ from ..scoring.types import ComplianceScore
8
+ from .url_security import URLSecurityValidator
9
+
10
+
11
+ class MessageValidator:
12
+ """Validates A2A message structure and content (per official A2A spec)."""
13
+
14
+ # Based on official A2A specification from a2a-python SDK
15
+ REQUIRED_FIELDS = ["messageId", "role", "parts"]
16
+ VALID_ROLES = ["agent", "user"]
17
+ VALID_PART_KINDS = ["text", "file", "data"]
18
+
19
+ def __init__(self) -> None:
20
+ """Initialize message validator."""
21
+ self._url_validator = URLSecurityValidator()
22
+ self._trust_scorer = TrustScorer()
23
+ self._availability_scorer = AvailabilityScorer()
24
+
25
+ def validate(self, message: Dict[str, Any], skip_signature_verification: bool = True) -> ValidationResult:
26
+ """
27
+ Validate an A2A message against official specification.
28
+
29
+ Args:
30
+ message: The message to validate (dict representation of Message object)
31
+ skip_signature_verification: Whether to skip signature verification
32
+
33
+ Returns:
34
+ ValidationResult with three-dimensional scoring
35
+ """
36
+ issues: List[ValidationIssue] = []
37
+
38
+ # Check required fields
39
+ for field in self.REQUIRED_FIELDS:
40
+ if field not in message:
41
+ issues.append(
42
+ ValidationIssue(
43
+ severity=ValidationSeverity.ERROR,
44
+ code="MISSING_REQUIRED_FIELD",
45
+ message=f"Required field '{field}' is missing",
46
+ path=field,
47
+ )
48
+ )
49
+
50
+ # Validate messageId
51
+ if "messageId" in message:
52
+ if not isinstance(message["messageId"], str):
53
+ issues.append(
54
+ ValidationIssue(
55
+ severity=ValidationSeverity.ERROR,
56
+ code="INVALID_TYPE",
57
+ message="messageId must be a string",
58
+ path="messageId",
59
+ )
60
+ )
61
+ elif not message["messageId"]:
62
+ issues.append(
63
+ ValidationIssue(
64
+ severity=ValidationSeverity.ERROR,
65
+ code="EMPTY_FIELD",
66
+ message="messageId cannot be empty",
67
+ path="messageId",
68
+ )
69
+ )
70
+
71
+ # Validate role
72
+ if "role" in message:
73
+ if not isinstance(message["role"], str):
74
+ issues.append(
75
+ ValidationIssue(
76
+ severity=ValidationSeverity.ERROR,
77
+ code="INVALID_TYPE",
78
+ message="role must be a string",
79
+ path="role",
80
+ )
81
+ )
82
+ elif message["role"] not in self.VALID_ROLES:
83
+ issues.append(
84
+ ValidationIssue(
85
+ severity=ValidationSeverity.ERROR,
86
+ code="INVALID_VALUE",
87
+ message=f"role must be one of {self.VALID_ROLES}, got '{message['role']}'",
88
+ path="role",
89
+ )
90
+ )
91
+
92
+ # Validate parts
93
+ if "parts" in message:
94
+ if not isinstance(message["parts"], list):
95
+ issues.append(
96
+ ValidationIssue(
97
+ severity=ValidationSeverity.ERROR,
98
+ code="INVALID_TYPE",
99
+ message="parts must be an array",
100
+ path="parts",
101
+ )
102
+ )
103
+ else:
104
+ if len(message["parts"]) == 0:
105
+ issues.append(
106
+ ValidationIssue(
107
+ severity=ValidationSeverity.WARNING,
108
+ code="EMPTY_ARRAY",
109
+ message="parts array is empty",
110
+ path="parts",
111
+ )
112
+ )
113
+ else:
114
+ parts_issues = self._validate_parts(message["parts"])
115
+ issues.extend(parts_issues)
116
+
117
+ # Validate optional fields if present
118
+ if "contextId" in message and message["contextId"] is not None:
119
+ if not isinstance(message["contextId"], str):
120
+ issues.append(
121
+ ValidationIssue(
122
+ severity=ValidationSeverity.WARNING,
123
+ code="INVALID_TYPE",
124
+ message="contextId must be a string if provided",
125
+ path="contextId",
126
+ )
127
+ )
128
+
129
+ if "taskId" in message and message["taskId"] is not None:
130
+ if not isinstance(message["taskId"], str):
131
+ issues.append(
132
+ ValidationIssue(
133
+ severity=ValidationSeverity.WARNING,
134
+ code="INVALID_TYPE",
135
+ message="taskId must be a string if provided",
136
+ path="taskId",
137
+ )
138
+ )
139
+
140
+ # Calculate compliance score (message structure adherence)
141
+ compliance = self._calculate_message_compliance(message, issues)
142
+
143
+ # Trust score not applicable to messages (only for agent cards)
144
+ from ..scoring.types import TrustScore, TrustBreakdown, SignaturesBreakdown, ProviderBreakdown, SecurityBreakdown, DocumentationBreakdown, TrustRating
145
+ trust = TrustScore(
146
+ total=0,
147
+ raw_score=0,
148
+ rating=TrustRating.UNTRUSTED,
149
+ confidence_multiplier=0.6,
150
+ breakdown=TrustBreakdown(
151
+ signatures=SignaturesBreakdown(score=0, max_score=0, tested=False, has_valid_signature=False, multiple_signatures=False, covers_all_fields=False, is_recent=False, has_invalid_signature=False, has_expired_signature=False),
152
+ provider=ProviderBreakdown(score=0, max_score=0, tested=False, has_organization=False, has_url=False, url_reachable=None),
153
+ security=SecurityBreakdown(score=0, max_score=0, https_only=False, has_security_schemes=False, has_strong_auth=False, has_http_urls=False),
154
+ documentation=DocumentationBreakdown(score=0, max_score=0, has_documentation_url=False, has_terms_of_service=False, has_privacy_policy=False)
155
+ ),
156
+ issues=["Trust scoring not applicable to runtime messages (only for agent discovery/cards)"],
157
+ partial_validation=True
158
+ )
159
+
160
+ # Availability not applicable for message validation
161
+ availability = self._availability_scorer.score_not_tested("Not applicable for message validation")
162
+
163
+ # Determine success
164
+ has_errors = any(i.severity == ValidationSeverity.ERROR for i in issues)
165
+
166
+ return ValidationResult(
167
+ success=not has_errors,
168
+ compliance=compliance,
169
+ trust=trust,
170
+ availability=availability,
171
+ issues=issues,
172
+ )
173
+
174
+ def _calculate_message_compliance(self, message: Dict[str, Any], issues: List[ValidationIssue]) -> "ComplianceScore":
175
+ """Calculate compliance score for message structure per A2A spec.
176
+
177
+ Official A2A message structure scoring:
178
+ - Required fields present (messageId, role, parts): 60 points
179
+ - Valid types and values: 20 points
180
+ - Valid parts structure: 15 points
181
+ - Data quality (non-empty messageId, valid part kinds): 5 points
182
+ """
183
+ from ..scoring.types import (
184
+ ComplianceScore, ComplianceBreakdown,
185
+ CoreFieldsBreakdown, SkillsQualityBreakdown,
186
+ FormatComplianceBreakdown, DataQualityBreakdown,
187
+ get_compliance_rating
188
+ )
189
+
190
+ score = 100
191
+
192
+ # Check for missing required fields (60 points total, 20 per field)
193
+ missing_fields = [f for f in self.REQUIRED_FIELDS if f not in message]
194
+ score -= len(missing_fields) * 20
195
+
196
+ # Check for type errors (20 points)
197
+ type_errors = [i for i in issues if i.code == "INVALID_TYPE"]
198
+ score -= min(len(type_errors) * 5, 20)
199
+
200
+ # Check for invalid values (role, part kinds) (15 points)
201
+ value_errors = [i for i in issues if i.code in ("INVALID_VALUE", "UNKNOWN_TYPE")]
202
+ score -= min(len(value_errors) * 5, 15)
203
+
204
+ # Check parts errors and data quality (5 points)
205
+ parts_errors = [i for i in issues if i.path and "parts" in i.path and i.severity == ValidationSeverity.ERROR]
206
+ score -= min(len(parts_errors) * 2, 5)
207
+
208
+ score = max(0, min(100, score))
209
+
210
+ # Create simplified breakdown
211
+ present_fields = [f for f in self.REQUIRED_FIELDS if f in message]
212
+ breakdown = ComplianceBreakdown(
213
+ core_fields=CoreFieldsBreakdown(
214
+ score=int(len(present_fields) / len(self.REQUIRED_FIELDS) * 60),
215
+ max_score=60,
216
+ present=present_fields,
217
+ missing=missing_fields
218
+ ),
219
+ skills_quality=SkillsQualityBreakdown(score=0, max_score=0), # N/A for messages
220
+ format_compliance=FormatComplianceBreakdown(
221
+ score=20 - min(len(type_errors) * 5, 20),
222
+ max_score=20,
223
+ valid_semver=True, # N/A
224
+ valid_protocol_version=True, # N/A
225
+ valid_url=True, # N/A for messages
226
+ valid_transports=True, # N/A
227
+ valid_mime_types=True # N/A
228
+ ),
229
+ data_quality=DataQualityBreakdown(
230
+ score=20 - min(len(value_errors) * 5 + len(parts_errors) * 2, 20),
231
+ max_score=20,
232
+ no_duplicate_skill_ids=True, # N/A
233
+ field_lengths_valid=bool("messageId" in message and message.get("messageId")),
234
+ no_ssrf_risks=len([i for i in issues if "SSRF" in i.code or (i.path and "uri" in i.path.lower())]) == 0
235
+ )
236
+ )
237
+
238
+ issue_messages = [i.message for i in issues if i.severity == ValidationSeverity.ERROR]
239
+
240
+ return ComplianceScore(
241
+ total=score,
242
+ rating=get_compliance_rating(score),
243
+ breakdown=breakdown,
244
+ issues=issue_messages
245
+ )
246
+
247
+ def _validate_parts(self, parts: List[Any]) -> List[ValidationIssue]:
248
+ """Validate message parts array (per A2A spec: TextPart, FilePart, DataPart)."""
249
+ issues: List[ValidationIssue] = []
250
+
251
+ for i, part in enumerate(parts):
252
+ if not isinstance(part, dict):
253
+ issues.append(
254
+ ValidationIssue(
255
+ severity=ValidationSeverity.ERROR,
256
+ code="INVALID_TYPE",
257
+ message=f"Part {i} must be an object",
258
+ path=f"parts[{i}]",
259
+ )
260
+ )
261
+ continue
262
+
263
+ # Check for 'kind' field (discriminator for Part types)
264
+ if "kind" not in part:
265
+ issues.append(
266
+ ValidationIssue(
267
+ severity=ValidationSeverity.ERROR,
268
+ code="MISSING_FIELD",
269
+ message=f"Part {i} missing 'kind' field",
270
+ path=f"parts[{i}].kind",
271
+ )
272
+ )
273
+ continue
274
+
275
+ kind = part["kind"]
276
+ if kind not in self.VALID_PART_KINDS:
277
+ issues.append(
278
+ ValidationIssue(
279
+ severity=ValidationSeverity.WARNING,
280
+ code="UNKNOWN_TYPE",
281
+ message=f"Part {i} has unknown kind '{kind}' (expected: {self.VALID_PART_KINDS})",
282
+ path=f"parts[{i}].kind",
283
+ )
284
+ )
285
+
286
+ # Validate based on part type
287
+ if kind == "text":
288
+ if "text" not in part:
289
+ issues.append(
290
+ ValidationIssue(
291
+ severity=ValidationSeverity.ERROR,
292
+ code="MISSING_FIELD",
293
+ message=f"TextPart {i} must have 'text' field",
294
+ path=f"parts[{i}].text",
295
+ )
296
+ )
297
+ elif not isinstance(part["text"], str):
298
+ issues.append(
299
+ ValidationIssue(
300
+ severity=ValidationSeverity.ERROR,
301
+ code="INVALID_TYPE",
302
+ message=f"TextPart {i} 'text' must be a string",
303
+ path=f"parts[{i}].text",
304
+ )
305
+ )
306
+
307
+ elif kind == "file":
308
+ if "file" not in part:
309
+ issues.append(
310
+ ValidationIssue(
311
+ severity=ValidationSeverity.ERROR,
312
+ code="MISSING_FIELD",
313
+ message=f"FilePart {i} must have 'file' field",
314
+ path=f"parts[{i}].file",
315
+ )
316
+ )
317
+ else:
318
+ file_obj = part["file"]
319
+ if not isinstance(file_obj, dict):
320
+ issues.append(
321
+ ValidationIssue(
322
+ severity=ValidationSeverity.ERROR,
323
+ code="INVALID_TYPE",
324
+ message=f"FilePart {i} 'file' must be an object",
325
+ path=f"parts[{i}].file",
326
+ )
327
+ )
328
+ else:
329
+ # Must have either 'bytes' or 'uri'
330
+ if "bytes" not in file_obj and "uri" not in file_obj:
331
+ issues.append(
332
+ ValidationIssue(
333
+ severity=ValidationSeverity.ERROR,
334
+ code="MISSING_FIELD",
335
+ message=f"FilePart {i} must have either 'bytes' or 'uri'",
336
+ path=f"parts[{i}].file",
337
+ )
338
+ )
339
+
340
+ elif kind == "data":
341
+ if "data" not in part:
342
+ issues.append(
343
+ ValidationIssue(
344
+ severity=ValidationSeverity.ERROR,
345
+ code="MISSING_FIELD",
346
+ message=f"DataPart {i} must have 'data' field",
347
+ path=f"parts[{i}].data",
348
+ )
349
+ )
350
+ elif not isinstance(part["data"], dict):
351
+ issues.append(
352
+ ValidationIssue(
353
+ severity=ValidationSeverity.ERROR,
354
+ code="INVALID_TYPE",
355
+ message=f"DataPart {i} 'data' must be an object",
356
+ path=f"parts[{i}].data",
357
+ )
358
+ )
359
+
360
+ return issues
@@ -0,0 +1,162 @@
1
+ """Protocol-level validation logic."""
2
+ from typing import Dict, List, Optional
3
+ from ..types import ValidationResult, ValidationIssue, ValidationSeverity, create_simple_validation_result
4
+ from .semver import SemverValidator
5
+
6
+
7
+ class ProtocolValidator:
8
+ """Validates A2A protocol compliance."""
9
+
10
+ SUPPORTED_VERSIONS = ["1.0", "1.0.0", "0.3.0"]
11
+ VALID_MESSAGE_TYPES = ["request", "response", "event", "error"]
12
+
13
+ def __init__(self) -> None:
14
+ """Initialize protocol validator."""
15
+ self._semver_validator = SemverValidator()
16
+
17
+ def validate_protocol_version(self, version: str) -> ValidationResult:
18
+ """
19
+ Validate A2A protocol version.
20
+
21
+ Args:
22
+ version: Protocol version string
23
+
24
+ Returns:
25
+ ValidationResult indicating if version is supported
26
+ """
27
+ issues: List[ValidationIssue] = []
28
+ score = 100
29
+
30
+ if not version:
31
+ issues.append(
32
+ ValidationIssue(
33
+ severity=ValidationSeverity.ERROR,
34
+ code="MISSING_VERSION",
35
+ message="Protocol version is required",
36
+ path="version",
37
+ )
38
+ )
39
+ score = 0
40
+ else:
41
+ # Validate semver format
42
+ semver_result = self._semver_validator.validate_version(version, "protocolVersion")
43
+ issues.extend(semver_result.issues)
44
+
45
+ # Check if version is supported
46
+ if version not in self.SUPPORTED_VERSIONS:
47
+ issues.append(
48
+ ValidationIssue(
49
+ severity=ValidationSeverity.WARNING,
50
+ code="UNSUPPORTED_VERSION",
51
+ message=f"Protocol version '{version}' may not be supported",
52
+ path="version",
53
+ )
54
+ )
55
+ score = 60
56
+
57
+ return create_simple_validation_result(
58
+ success=score > 0 and not any(i.severity == ValidationSeverity.ERROR for i in issues),
59
+ issues=issues,
60
+ simple_score=score,
61
+ dimension="compliance"
62
+ )
63
+
64
+ def validate_headers(self, headers: Dict[str, str]) -> ValidationResult:
65
+ """
66
+ Validate A2A protocol headers.
67
+
68
+ Args:
69
+ headers: HTTP headers dictionary
70
+
71
+ Returns:
72
+ ValidationResult for headers
73
+ """
74
+ issues: List[ValidationIssue] = []
75
+ score = 100
76
+
77
+ # Check Content-Type
78
+ content_type = headers.get("content-type", "").lower()
79
+ if content_type and "application/json" not in content_type:
80
+ issues.append(
81
+ ValidationIssue(
82
+ severity=ValidationSeverity.WARNING,
83
+ code="UNEXPECTED_CONTENT_TYPE",
84
+ message=f"Expected application/json, got {content_type}",
85
+ path="headers.content-type",
86
+ )
87
+ )
88
+ score -= 10
89
+
90
+ # Check for A2A version header
91
+ if "x-a2a-version" not in headers and "X-A2A-Version" not in headers:
92
+ issues.append(
93
+ ValidationIssue(
94
+ severity=ValidationSeverity.WARNING,
95
+ code="MISSING_HEADER",
96
+ message="X-A2A-Version header is recommended",
97
+ path="headers.x-a2a-version",
98
+ )
99
+ )
100
+ score -= 5
101
+
102
+ # Check for suspicious headers
103
+ for header in headers:
104
+ if header.lower().startswith("x-forwarded-"):
105
+ issues.append(
106
+ ValidationIssue(
107
+ severity=ValidationSeverity.INFO,
108
+ code="PROXY_HEADER",
109
+ message=f"Proxy header detected: {header}",
110
+ path=f"headers.{header}",
111
+ )
112
+ )
113
+
114
+ score = max(0, score)
115
+
116
+ return create_simple_validation_result(
117
+ success=score >= 70,
118
+ issues=issues,
119
+ simple_score=score,
120
+ dimension="compliance"
121
+ )
122
+
123
+ def validate_message_type(self, message_type: Optional[str]) -> ValidationResult:
124
+ """
125
+ Validate message type.
126
+
127
+ Args:
128
+ message_type: Type of message
129
+
130
+ Returns:
131
+ ValidationResult for message type
132
+ """
133
+ issues: List[ValidationIssue] = []
134
+ score = 100
135
+
136
+ if not message_type:
137
+ issues.append(
138
+ ValidationIssue(
139
+ severity=ValidationSeverity.WARNING,
140
+ code="MISSING_MESSAGE_TYPE",
141
+ message="Message type not specified",
142
+ path="type",
143
+ )
144
+ )
145
+ score = 70
146
+ elif message_type not in self.VALID_MESSAGE_TYPES:
147
+ issues.append(
148
+ ValidationIssue(
149
+ severity=ValidationSeverity.WARNING,
150
+ code="UNKNOWN_MESSAGE_TYPE",
151
+ message=f"Unknown message type: {message_type}",
152
+ path="type",
153
+ )
154
+ )
155
+ score = 80
156
+
157
+ return create_simple_validation_result(
158
+ success=True, # Message type is informational
159
+ issues=issues,
160
+ simple_score=score,
161
+ dimension="compliance"
162
+ )