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.
- capiscio_sdk/__init__.py +42 -0
- capiscio_sdk/config.py +114 -0
- capiscio_sdk/errors.py +69 -0
- capiscio_sdk/executor.py +216 -0
- capiscio_sdk/infrastructure/__init__.py +5 -0
- capiscio_sdk/infrastructure/cache.py +73 -0
- capiscio_sdk/infrastructure/rate_limiter.py +110 -0
- capiscio_sdk/py.typed +0 -0
- capiscio_sdk/scoring/__init__.py +42 -0
- capiscio_sdk/scoring/availability.py +299 -0
- capiscio_sdk/scoring/compliance.py +314 -0
- capiscio_sdk/scoring/trust.py +340 -0
- capiscio_sdk/scoring/types.py +353 -0
- capiscio_sdk/types.py +234 -0
- capiscio_sdk/validators/__init__.py +18 -0
- capiscio_sdk/validators/agent_card.py +444 -0
- capiscio_sdk/validators/certificate.py +384 -0
- capiscio_sdk/validators/message.py +360 -0
- capiscio_sdk/validators/protocol.py +162 -0
- capiscio_sdk/validators/semver.py +202 -0
- capiscio_sdk/validators/signature.py +234 -0
- capiscio_sdk/validators/url_security.py +269 -0
- capiscio_sdk-0.2.0.dist-info/METADATA +221 -0
- capiscio_sdk-0.2.0.dist-info/RECORD +26 -0
- capiscio_sdk-0.2.0.dist-info/WHEEL +4 -0
- capiscio_sdk-0.2.0.dist-info/licenses/LICENSE +190 -0
capiscio_sdk/types.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Core types for Capiscio A2A Security."""
|
|
2
|
+
from typing import List, Dict, Any, Optional
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationSeverity(str, Enum):
|
|
9
|
+
"""Severity level for validation issues."""
|
|
10
|
+
|
|
11
|
+
ERROR = "error"
|
|
12
|
+
WARNING = "warning"
|
|
13
|
+
INFO = "info"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationIssue(BaseModel):
|
|
17
|
+
"""A single validation issue."""
|
|
18
|
+
|
|
19
|
+
severity: ValidationSeverity
|
|
20
|
+
code: str
|
|
21
|
+
message: str
|
|
22
|
+
path: Optional[str] = None
|
|
23
|
+
details: Optional[Dict[str, Any]] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ValidationResult(BaseModel):
|
|
27
|
+
"""Result of a validation operation with multi-dimensional scoring.
|
|
28
|
+
|
|
29
|
+
Provides three independent score dimensions:
|
|
30
|
+
- compliance: A2A protocol specification adherence (0-100)
|
|
31
|
+
- trust: Security and authenticity signals (0-100)
|
|
32
|
+
- availability: Operational readiness (0-100, optional)
|
|
33
|
+
|
|
34
|
+
The legacy `score` field is maintained for backward compatibility
|
|
35
|
+
and returns the compliance.total value.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
success: bool
|
|
39
|
+
issues: List[ValidationIssue] = Field(default_factory=list)
|
|
40
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
# Three-dimensional scores
|
|
43
|
+
compliance: Optional["ComplianceScore"] = None
|
|
44
|
+
trust: Optional["TrustScore"] = None
|
|
45
|
+
availability: Optional["AvailabilityScore"] = None
|
|
46
|
+
|
|
47
|
+
# Legacy single score (deprecated, for backward compatibility)
|
|
48
|
+
legacy_score: Optional[int] = Field(default=None, alias="score")
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def score(self) -> int:
|
|
52
|
+
"""Legacy score property for backward compatibility.
|
|
53
|
+
|
|
54
|
+
Returns the compliance score total. This property is deprecated.
|
|
55
|
+
Use result.compliance.total instead.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Compliance score (0-100)
|
|
59
|
+
"""
|
|
60
|
+
warnings.warn(
|
|
61
|
+
"The 'score' property is deprecated. Use 'compliance.total', "
|
|
62
|
+
"'trust.total', or 'availability.total' instead for more specific scoring.",
|
|
63
|
+
DeprecationWarning,
|
|
64
|
+
stacklevel=2
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Return compliance score if available, otherwise legacy score, otherwise 0
|
|
68
|
+
if self.compliance:
|
|
69
|
+
return self.compliance.total
|
|
70
|
+
if self.legacy_score is not None:
|
|
71
|
+
return self.legacy_score
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def recommendation(self) -> str:
|
|
76
|
+
"""Get overall recommendation based on all score dimensions.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Human-readable recommendation string
|
|
80
|
+
"""
|
|
81
|
+
if not self.compliance or not self.trust:
|
|
82
|
+
return "Incomplete validation - run full validation for recommendations"
|
|
83
|
+
|
|
84
|
+
compliance_total = self.compliance.total
|
|
85
|
+
trust_total = self.trust.total
|
|
86
|
+
|
|
87
|
+
# Both high
|
|
88
|
+
if compliance_total >= 90 and trust_total >= 80:
|
|
89
|
+
return "Excellent - Highly compliant and trusted agent"
|
|
90
|
+
|
|
91
|
+
# High compliance, lower trust
|
|
92
|
+
if compliance_total >= 90 and trust_total < 60:
|
|
93
|
+
return "Good compliance but low trust - verify signatures and security"
|
|
94
|
+
|
|
95
|
+
# High trust, lower compliance
|
|
96
|
+
if trust_total >= 80 and compliance_total < 75:
|
|
97
|
+
return "Trusted but not fully compliant - fix spec violations"
|
|
98
|
+
|
|
99
|
+
# Both moderate
|
|
100
|
+
if compliance_total >= 60 and trust_total >= 40:
|
|
101
|
+
return "Acceptable - improvements recommended"
|
|
102
|
+
|
|
103
|
+
# Low scores
|
|
104
|
+
return "Not recommended - significant issues found"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def errors(self) -> List[ValidationIssue]:
|
|
108
|
+
"""Get only error-level issues."""
|
|
109
|
+
return [i for i in self.issues if i.severity == ValidationSeverity.ERROR]
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def warnings(self) -> List[ValidationIssue]:
|
|
113
|
+
"""Get only warning-level issues."""
|
|
114
|
+
return [i for i in self.issues if i.severity == ValidationSeverity.WARNING]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Import here to avoid circular dependency
|
|
118
|
+
from .scoring.types import ComplianceScore, TrustScore, AvailabilityScore # noqa: E402
|
|
119
|
+
|
|
120
|
+
# Update forward references
|
|
121
|
+
ValidationResult.model_rebuild()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Helper function for simple validators
|
|
125
|
+
def create_simple_validation_result(
|
|
126
|
+
success: bool,
|
|
127
|
+
issues: List[ValidationIssue],
|
|
128
|
+
simple_score: int = 100,
|
|
129
|
+
dimension: str = "compliance"
|
|
130
|
+
) -> ValidationResult:
|
|
131
|
+
"""Create a ValidationResult for simple validators that don't need full scoring.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
success: Whether validation succeeded
|
|
135
|
+
issues: List of validation issues
|
|
136
|
+
simple_score: Simple 0-100 score
|
|
137
|
+
dimension: Which dimension to apply score to ("compliance" or "trust")
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
ValidationResult with simplified scoring
|
|
141
|
+
"""
|
|
142
|
+
from .scoring.types import (
|
|
143
|
+
ComplianceScore, TrustScore, ComplianceBreakdown, TrustBreakdown,
|
|
144
|
+
CoreFieldsBreakdown, SkillsQualityBreakdown,
|
|
145
|
+
FormatComplianceBreakdown, DataQualityBreakdown,
|
|
146
|
+
SignaturesBreakdown, ProviderBreakdown,
|
|
147
|
+
SecurityBreakdown, DocumentationBreakdown,
|
|
148
|
+
get_compliance_rating, get_trust_rating
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
issue_messages = [i.message for i in issues if i.severity == ValidationSeverity.ERROR]
|
|
152
|
+
|
|
153
|
+
if dimension == "compliance":
|
|
154
|
+
# Create minimal compliance score
|
|
155
|
+
compliance = ComplianceScore(
|
|
156
|
+
total=simple_score,
|
|
157
|
+
rating=get_compliance_rating(simple_score),
|
|
158
|
+
breakdown=ComplianceBreakdown(
|
|
159
|
+
core_fields=CoreFieldsBreakdown(score=simple_score, present=[], missing=[]),
|
|
160
|
+
skills_quality=SkillsQualityBreakdown(score=0),
|
|
161
|
+
format_compliance=FormatComplianceBreakdown(score=0),
|
|
162
|
+
data_quality=DataQualityBreakdown(score=0)
|
|
163
|
+
),
|
|
164
|
+
issues=issue_messages
|
|
165
|
+
)
|
|
166
|
+
trust = TrustScore(
|
|
167
|
+
total=0,
|
|
168
|
+
raw_score=0,
|
|
169
|
+
confidence_multiplier=0.6,
|
|
170
|
+
rating=get_trust_rating(0),
|
|
171
|
+
breakdown=TrustBreakdown(
|
|
172
|
+
signatures=SignaturesBreakdown(score=0),
|
|
173
|
+
provider=ProviderBreakdown(score=0),
|
|
174
|
+
security=SecurityBreakdown(score=0),
|
|
175
|
+
documentation=DocumentationBreakdown(score=0)
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
else: # trust
|
|
179
|
+
compliance = ComplianceScore(
|
|
180
|
+
total=0,
|
|
181
|
+
rating=get_compliance_rating(0),
|
|
182
|
+
breakdown=ComplianceBreakdown(
|
|
183
|
+
core_fields=CoreFieldsBreakdown(score=0, present=[], missing=[]),
|
|
184
|
+
skills_quality=SkillsQualityBreakdown(score=0),
|
|
185
|
+
format_compliance=FormatComplianceBreakdown(score=0),
|
|
186
|
+
data_quality=DataQualityBreakdown(score=0)
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
trust = TrustScore(
|
|
190
|
+
total=simple_score,
|
|
191
|
+
raw_score=simple_score,
|
|
192
|
+
confidence_multiplier=1.0,
|
|
193
|
+
rating=get_trust_rating(simple_score),
|
|
194
|
+
breakdown=TrustBreakdown(
|
|
195
|
+
signatures=SignaturesBreakdown(score=simple_score),
|
|
196
|
+
provider=ProviderBreakdown(score=0),
|
|
197
|
+
security=SecurityBreakdown(score=0),
|
|
198
|
+
documentation=DocumentationBreakdown(score=0)
|
|
199
|
+
),
|
|
200
|
+
issues=issue_messages
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
from .scoring import AvailabilityScorer
|
|
204
|
+
availability_scorer = AvailabilityScorer()
|
|
205
|
+
availability = availability_scorer.score_not_tested("Not applicable")
|
|
206
|
+
|
|
207
|
+
return ValidationResult(
|
|
208
|
+
success=success,
|
|
209
|
+
compliance=compliance,
|
|
210
|
+
trust=trust,
|
|
211
|
+
availability=availability,
|
|
212
|
+
issues=issues
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class CacheEntry(BaseModel):
|
|
217
|
+
"""Cached validation result with TTL."""
|
|
218
|
+
|
|
219
|
+
result: ValidationResult
|
|
220
|
+
cached_at: float # Unix timestamp
|
|
221
|
+
ttl: int # Seconds
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class RateLimitInfo(BaseModel):
|
|
225
|
+
"""Rate limit information."""
|
|
226
|
+
|
|
227
|
+
requests_allowed: int
|
|
228
|
+
requests_used: int
|
|
229
|
+
reset_at: float # Unix timestamp
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def requests_remaining(self) -> int:
|
|
233
|
+
"""Remaining requests."""
|
|
234
|
+
return max(0, self.requests_allowed - self.requests_used)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Validators for A2A message components."""
|
|
2
|
+
from .message import MessageValidator
|
|
3
|
+
from .protocol import ProtocolValidator
|
|
4
|
+
from .url_security import URLSecurityValidator
|
|
5
|
+
from .signature import SignatureValidator
|
|
6
|
+
from .semver import SemverValidator
|
|
7
|
+
from .agent_card import AgentCardValidator
|
|
8
|
+
from .certificate import CertificateValidator
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MessageValidator",
|
|
12
|
+
"ProtocolValidator",
|
|
13
|
+
"URLSecurityValidator",
|
|
14
|
+
"SignatureValidator",
|
|
15
|
+
"SemverValidator",
|
|
16
|
+
"AgentCardValidator",
|
|
17
|
+
"CertificateValidator",
|
|
18
|
+
]
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Agent Card validation for A2A protocol.
|
|
2
|
+
|
|
3
|
+
This module provides validation for Agent Card discovery documents as specified
|
|
4
|
+
in the A2A protocol. It validates schema structure, required fields, capabilities,
|
|
5
|
+
skills, and provider information.
|
|
6
|
+
|
|
7
|
+
Validates:
|
|
8
|
+
- Schema structure and required fields
|
|
9
|
+
- URL validity and security
|
|
10
|
+
- Protocol version compatibility
|
|
11
|
+
- Capabilities configuration
|
|
12
|
+
- Skills definitions
|
|
13
|
+
- Provider information
|
|
14
|
+
- Transport configuration
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
import httpx
|
|
19
|
+
from ..types import ValidationResult, ValidationIssue, ValidationSeverity
|
|
20
|
+
from ..scoring import ComplianceScorer, TrustScorer, AvailabilityScorer
|
|
21
|
+
from .url_security import URLSecurityValidator
|
|
22
|
+
from .semver import SemverValidator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentCardValidator:
|
|
26
|
+
"""Validates Agent Card discovery documents per A2A specification.
|
|
27
|
+
|
|
28
|
+
Agent Cards are static metadata documents used for agent discovery.
|
|
29
|
+
This validator checks schema compliance, security requirements, and
|
|
30
|
+
configuration correctness.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Required fields per A2A specification
|
|
34
|
+
REQUIRED_FIELDS = [
|
|
35
|
+
"name",
|
|
36
|
+
"description",
|
|
37
|
+
"url",
|
|
38
|
+
"version",
|
|
39
|
+
"protocolVersion",
|
|
40
|
+
"preferredTransport",
|
|
41
|
+
"capabilities",
|
|
42
|
+
"provider",
|
|
43
|
+
"skills"
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Valid transport protocols per A2A spec
|
|
47
|
+
VALID_TRANSPORTS = ["JSONRPC", "GRPC", "HTTP+JSON"]
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
52
|
+
url_validator: Optional[URLSecurityValidator] = None,
|
|
53
|
+
semver_validator: Optional[SemverValidator] = None
|
|
54
|
+
):
|
|
55
|
+
"""Initialize agent card validator.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
http_client: Optional HTTP client for fetching cards
|
|
59
|
+
url_validator: Optional URL security validator
|
|
60
|
+
semver_validator: Optional semantic version validator
|
|
61
|
+
"""
|
|
62
|
+
self.http_client = http_client or httpx.AsyncClient(timeout=10.0)
|
|
63
|
+
self.url_validator = url_validator or URLSecurityValidator()
|
|
64
|
+
self.semver_validator = semver_validator or SemverValidator()
|
|
65
|
+
|
|
66
|
+
# Initialize scorers
|
|
67
|
+
self.compliance_scorer = ComplianceScorer()
|
|
68
|
+
self.trust_scorer = TrustScorer()
|
|
69
|
+
self.availability_scorer = AvailabilityScorer()
|
|
70
|
+
|
|
71
|
+
async def fetch_and_validate(self, agent_url: str) -> ValidationResult:
|
|
72
|
+
"""Fetch agent card from URL and validate it.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
agent_url: Base URL of the agent
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
ValidationResult with issues and score
|
|
79
|
+
"""
|
|
80
|
+
issues: List[ValidationIssue] = []
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Validate the agent URL first
|
|
84
|
+
url_result = self.url_validator.validate_url(agent_url, field_name="agent_url", require_https=True)
|
|
85
|
+
if not url_result.success:
|
|
86
|
+
issues.extend(url_result.issues)
|
|
87
|
+
# Return with zero scores for all dimensions
|
|
88
|
+
return ValidationResult(
|
|
89
|
+
success=False,
|
|
90
|
+
compliance=self.compliance_scorer.score_agent_card({}, issues),
|
|
91
|
+
trust=self.trust_scorer.score_agent_card({}, issues),
|
|
92
|
+
availability=self.availability_scorer.score_not_tested("URL validation failed"),
|
|
93
|
+
issues=issues
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Fetch agent card from well-known location
|
|
97
|
+
card_url = f"{agent_url.rstrip('/')}/.well-known/agent-card.json"
|
|
98
|
+
response = await self.http_client.get(card_url)
|
|
99
|
+
response.raise_for_status()
|
|
100
|
+
|
|
101
|
+
card_data = response.json()
|
|
102
|
+
|
|
103
|
+
# Validate the fetched card
|
|
104
|
+
return self.validate_agent_card(card_data)
|
|
105
|
+
|
|
106
|
+
except httpx.HTTPStatusError as e:
|
|
107
|
+
issues.append(ValidationIssue(
|
|
108
|
+
severity=ValidationSeverity.ERROR,
|
|
109
|
+
code="AGENT_CARD_HTTP_ERROR",
|
|
110
|
+
message=f"Failed to fetch agent card (HTTP {e.response.status_code}): {str(e)}",
|
|
111
|
+
path="agent_card"
|
|
112
|
+
))
|
|
113
|
+
return ValidationResult(
|
|
114
|
+
success=False,
|
|
115
|
+
compliance=self.compliance_scorer.score_agent_card({}, issues),
|
|
116
|
+
trust=self.trust_scorer.score_agent_card({}, issues),
|
|
117
|
+
availability=self.availability_scorer.score_not_tested("HTTP error fetching card"),
|
|
118
|
+
issues=issues
|
|
119
|
+
)
|
|
120
|
+
except httpx.RequestError as e:
|
|
121
|
+
issues.append(ValidationIssue(
|
|
122
|
+
severity=ValidationSeverity.ERROR,
|
|
123
|
+
code="AGENT_CARD_FETCH_FAILED",
|
|
124
|
+
message=f"Failed to fetch agent card: {str(e)}",
|
|
125
|
+
path="agent_card"
|
|
126
|
+
))
|
|
127
|
+
return ValidationResult(
|
|
128
|
+
success=False,
|
|
129
|
+
compliance=self.compliance_scorer.score_agent_card({}, issues),
|
|
130
|
+
trust=self.trust_scorer.score_agent_card({}, issues),
|
|
131
|
+
availability=self.availability_scorer.score_not_tested("Network error fetching card"),
|
|
132
|
+
issues=issues
|
|
133
|
+
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
issues.append(ValidationIssue(
|
|
136
|
+
severity=ValidationSeverity.ERROR,
|
|
137
|
+
code="AGENT_CARD_VALIDATION_ERROR",
|
|
138
|
+
message=f"Agent card validation error: {str(e)}",
|
|
139
|
+
path="agent_card"
|
|
140
|
+
))
|
|
141
|
+
return ValidationResult(
|
|
142
|
+
success=False,
|
|
143
|
+
compliance=self.compliance_scorer.score_agent_card({}, issues),
|
|
144
|
+
trust=self.trust_scorer.score_agent_card({}, issues),
|
|
145
|
+
availability=self.availability_scorer.score_not_tested("Validation error"),
|
|
146
|
+
issues=issues
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def validate_agent_card(self, card: Dict[str, Any], skip_signature_verification: bool = True) -> ValidationResult:
|
|
150
|
+
"""Validate agent card structure and content.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
card: Agent card dictionary
|
|
154
|
+
skip_signature_verification: Whether to skip signature verification (default True for now)
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
ValidationResult with three-dimensional scoring
|
|
158
|
+
"""
|
|
159
|
+
issues: List[ValidationIssue] = []
|
|
160
|
+
|
|
161
|
+
# 1. Validate required fields
|
|
162
|
+
issues.extend(self._validate_required_fields(card))
|
|
163
|
+
|
|
164
|
+
# 2. Validate URL
|
|
165
|
+
if "url" in card:
|
|
166
|
+
url_result = self.url_validator.validate_url(card["url"], field_name="agent_card.url", require_https=True)
|
|
167
|
+
issues.extend(url_result.issues)
|
|
168
|
+
|
|
169
|
+
# 3. Validate protocol version
|
|
170
|
+
if "protocolVersion" in card:
|
|
171
|
+
version_result = self.semver_validator.validate_version(card["protocolVersion"])
|
|
172
|
+
issues.extend(version_result.issues)
|
|
173
|
+
|
|
174
|
+
# 4. Validate transport
|
|
175
|
+
if "preferredTransport" in card:
|
|
176
|
+
issues.extend(self._validate_transport(card["preferredTransport"]))
|
|
177
|
+
|
|
178
|
+
# 5. Validate capabilities
|
|
179
|
+
if "capabilities" in card:
|
|
180
|
+
issues.extend(self._validate_capabilities(card["capabilities"]))
|
|
181
|
+
|
|
182
|
+
# 6. Validate provider
|
|
183
|
+
if "provider" in card:
|
|
184
|
+
issues.extend(self._validate_provider(card["provider"]))
|
|
185
|
+
|
|
186
|
+
# 7. Validate skills
|
|
187
|
+
if "skills" in card:
|
|
188
|
+
issues.extend(self._validate_skills(card["skills"]))
|
|
189
|
+
|
|
190
|
+
# 8. Validate additional interfaces (if present)
|
|
191
|
+
if "additionalInterfaces" in card:
|
|
192
|
+
issues.extend(self._validate_additional_interfaces(card["additionalInterfaces"], card))
|
|
193
|
+
|
|
194
|
+
# Calculate three-dimensional scores
|
|
195
|
+
compliance = self.compliance_scorer.score_agent_card(card, issues)
|
|
196
|
+
trust = self.trust_scorer.score_agent_card(card, issues, skip_signature_verification)
|
|
197
|
+
availability = self.availability_scorer.score_not_tested("Schema-only validation")
|
|
198
|
+
|
|
199
|
+
# Determine success based on error presence
|
|
200
|
+
has_errors = any(i.severity == ValidationSeverity.ERROR for i in issues)
|
|
201
|
+
|
|
202
|
+
return ValidationResult(
|
|
203
|
+
success=not has_errors,
|
|
204
|
+
compliance=compliance,
|
|
205
|
+
trust=trust,
|
|
206
|
+
availability=availability,
|
|
207
|
+
issues=issues
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _validate_required_fields(self, card: Dict[str, Any]) -> List[ValidationIssue]:
|
|
211
|
+
"""Validate that all required fields are present."""
|
|
212
|
+
issues: List[ValidationIssue] = []
|
|
213
|
+
|
|
214
|
+
for field in self.REQUIRED_FIELDS:
|
|
215
|
+
if field not in card or card[field] is None:
|
|
216
|
+
issues.append(ValidationIssue(
|
|
217
|
+
severity=ValidationSeverity.ERROR,
|
|
218
|
+
code="MISSING_REQUIRED_FIELD",
|
|
219
|
+
message=f"Agent card missing required field: {field}",
|
|
220
|
+
path=f"agent_card.{field}"
|
|
221
|
+
))
|
|
222
|
+
elif isinstance(card[field], str) and not card[field].strip():
|
|
223
|
+
issues.append(ValidationIssue(
|
|
224
|
+
severity=ValidationSeverity.ERROR,
|
|
225
|
+
code="EMPTY_REQUIRED_FIELD",
|
|
226
|
+
message=f"Agent card required field is empty: {field}",
|
|
227
|
+
path=f"agent_card.{field}"
|
|
228
|
+
))
|
|
229
|
+
|
|
230
|
+
return issues
|
|
231
|
+
|
|
232
|
+
def _validate_transport(self, transport: str) -> List[ValidationIssue]:
|
|
233
|
+
"""Validate transport protocol."""
|
|
234
|
+
issues: List[ValidationIssue] = []
|
|
235
|
+
|
|
236
|
+
if transport not in self.VALID_TRANSPORTS:
|
|
237
|
+
issues.append(ValidationIssue(
|
|
238
|
+
severity=ValidationSeverity.ERROR,
|
|
239
|
+
code="INVALID_TRANSPORT",
|
|
240
|
+
message=f"Invalid transport protocol: {transport}. Valid options: {', '.join(self.VALID_TRANSPORTS)}",
|
|
241
|
+
path="agent_card.preferredTransport"
|
|
242
|
+
))
|
|
243
|
+
|
|
244
|
+
return issues
|
|
245
|
+
|
|
246
|
+
def _validate_capabilities(self, capabilities: Dict[str, Any]) -> List[ValidationIssue]:
|
|
247
|
+
"""Validate agent capabilities."""
|
|
248
|
+
issues: List[ValidationIssue] = []
|
|
249
|
+
|
|
250
|
+
if not isinstance(capabilities, dict):
|
|
251
|
+
issues.append(ValidationIssue(
|
|
252
|
+
severity=ValidationSeverity.ERROR,
|
|
253
|
+
code="INVALID_CAPABILITIES_TYPE",
|
|
254
|
+
message="Capabilities must be an object",
|
|
255
|
+
path="agent_card.capabilities"
|
|
256
|
+
))
|
|
257
|
+
return issues
|
|
258
|
+
|
|
259
|
+
# Validate boolean capability flags
|
|
260
|
+
boolean_capabilities = ["streaming", "pushNotifications", "batchProcessing"]
|
|
261
|
+
for cap in boolean_capabilities:
|
|
262
|
+
if cap in capabilities and not isinstance(capabilities[cap], bool):
|
|
263
|
+
issues.append(ValidationIssue(
|
|
264
|
+
severity=ValidationSeverity.WARNING,
|
|
265
|
+
code="INVALID_CAPABILITY_TYPE",
|
|
266
|
+
message=f"Capability {cap} should be boolean",
|
|
267
|
+
path=f"agent_card.capabilities.{cap}"
|
|
268
|
+
))
|
|
269
|
+
|
|
270
|
+
# Check for empty capabilities
|
|
271
|
+
if not capabilities:
|
|
272
|
+
issues.append(ValidationIssue(
|
|
273
|
+
severity=ValidationSeverity.WARNING,
|
|
274
|
+
code="EMPTY_CAPABILITIES",
|
|
275
|
+
message="Agent card has empty capabilities object",
|
|
276
|
+
path="agent_card.capabilities"
|
|
277
|
+
))
|
|
278
|
+
|
|
279
|
+
return issues
|
|
280
|
+
|
|
281
|
+
def _validate_provider(self, provider: Dict[str, Any]) -> List[ValidationIssue]:
|
|
282
|
+
"""Validate provider information."""
|
|
283
|
+
issues: List[ValidationIssue] = []
|
|
284
|
+
|
|
285
|
+
if not isinstance(provider, dict):
|
|
286
|
+
issues.append(ValidationIssue(
|
|
287
|
+
severity=ValidationSeverity.ERROR,
|
|
288
|
+
code="INVALID_PROVIDER_TYPE",
|
|
289
|
+
message="Provider must be an object",
|
|
290
|
+
path="agent_card.provider"
|
|
291
|
+
))
|
|
292
|
+
return issues
|
|
293
|
+
|
|
294
|
+
# Check required provider fields
|
|
295
|
+
required_provider_fields = ["name"]
|
|
296
|
+
for field in required_provider_fields:
|
|
297
|
+
if field not in provider or not provider[field]:
|
|
298
|
+
issues.append(ValidationIssue(
|
|
299
|
+
severity=ValidationSeverity.ERROR,
|
|
300
|
+
code="MISSING_PROVIDER_FIELD",
|
|
301
|
+
message=f"Provider missing required field: {field}",
|
|
302
|
+
path=f"agent_card.provider.{field}"
|
|
303
|
+
))
|
|
304
|
+
|
|
305
|
+
# Validate provider URL if present
|
|
306
|
+
if "url" in provider and provider["url"]:
|
|
307
|
+
url_result = self.url_validator.validate_url(provider["url"], field_name="agent_card.provider.url", require_https=False)
|
|
308
|
+
for issue in url_result.issues:
|
|
309
|
+
issues.append(ValidationIssue(
|
|
310
|
+
severity=issue.severity,
|
|
311
|
+
code=issue.code,
|
|
312
|
+
message=f"Provider URL: {issue.message}",
|
|
313
|
+
path="agent_card.provider.url"
|
|
314
|
+
))
|
|
315
|
+
|
|
316
|
+
return issues
|
|
317
|
+
|
|
318
|
+
def _validate_skills(self, skills: List[Dict[str, Any]]) -> List[ValidationIssue]:
|
|
319
|
+
"""Validate skills array."""
|
|
320
|
+
issues: List[ValidationIssue] = []
|
|
321
|
+
|
|
322
|
+
if not isinstance(skills, list):
|
|
323
|
+
issues.append(ValidationIssue(
|
|
324
|
+
severity=ValidationSeverity.ERROR,
|
|
325
|
+
code="INVALID_SKILLS_TYPE",
|
|
326
|
+
message="Skills must be an array",
|
|
327
|
+
path="agent_card.skills"
|
|
328
|
+
))
|
|
329
|
+
return issues
|
|
330
|
+
|
|
331
|
+
if not skills:
|
|
332
|
+
issues.append(ValidationIssue(
|
|
333
|
+
severity=ValidationSeverity.WARNING,
|
|
334
|
+
code="EMPTY_SKILLS",
|
|
335
|
+
message="Agent card has empty skills array",
|
|
336
|
+
path="agent_card.skills"
|
|
337
|
+
))
|
|
338
|
+
return issues
|
|
339
|
+
|
|
340
|
+
# Validate each skill
|
|
341
|
+
for idx, skill in enumerate(skills):
|
|
342
|
+
if not isinstance(skill, dict):
|
|
343
|
+
issues.append(ValidationIssue(
|
|
344
|
+
severity=ValidationSeverity.ERROR,
|
|
345
|
+
code="INVALID_SKILL_TYPE",
|
|
346
|
+
message=f"Skill at index {idx} must be an object",
|
|
347
|
+
path=f"agent_card.skills[{idx}]"
|
|
348
|
+
))
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
# Check required skill fields
|
|
352
|
+
if "name" not in skill or not skill["name"]:
|
|
353
|
+
issues.append(ValidationIssue(
|
|
354
|
+
severity=ValidationSeverity.ERROR,
|
|
355
|
+
code="MISSING_SKILL_NAME",
|
|
356
|
+
message=f"Skill at index {idx} missing name",
|
|
357
|
+
path=f"agent_card.skills[{idx}].name"
|
|
358
|
+
))
|
|
359
|
+
|
|
360
|
+
if "description" not in skill or not skill["description"]:
|
|
361
|
+
issues.append(ValidationIssue(
|
|
362
|
+
severity=ValidationSeverity.WARNING,
|
|
363
|
+
code="MISSING_SKILL_DESCRIPTION",
|
|
364
|
+
message=f"Skill '{skill.get('name', idx)}' missing description",
|
|
365
|
+
path=f"agent_card.skills[{idx}].description"
|
|
366
|
+
))
|
|
367
|
+
|
|
368
|
+
return issues
|
|
369
|
+
|
|
370
|
+
def _validate_additional_interfaces(
|
|
371
|
+
self,
|
|
372
|
+
interfaces: List[Dict[str, Any]],
|
|
373
|
+
card: Dict[str, Any]
|
|
374
|
+
) -> List[ValidationIssue]:
|
|
375
|
+
"""Validate additional interfaces configuration."""
|
|
376
|
+
issues: List[ValidationIssue] = []
|
|
377
|
+
|
|
378
|
+
if not isinstance(interfaces, list):
|
|
379
|
+
issues.append(ValidationIssue(
|
|
380
|
+
severity=ValidationSeverity.ERROR,
|
|
381
|
+
code="INVALID_INTERFACES_TYPE",
|
|
382
|
+
message="additionalInterfaces must be an array",
|
|
383
|
+
path="agent_card.additionalInterfaces"
|
|
384
|
+
))
|
|
385
|
+
return issues
|
|
386
|
+
|
|
387
|
+
# Track URLs and transports to detect conflicts
|
|
388
|
+
url_transport_map: Dict[str, str] = {}
|
|
389
|
+
main_url = card.get("url", "")
|
|
390
|
+
preferred_transport = card.get("preferredTransport", "JSONRPC")
|
|
391
|
+
|
|
392
|
+
if main_url:
|
|
393
|
+
url_transport_map[main_url] = preferred_transport
|
|
394
|
+
|
|
395
|
+
for idx, interface in enumerate(interfaces):
|
|
396
|
+
if not isinstance(interface, dict):
|
|
397
|
+
issues.append(ValidationIssue(
|
|
398
|
+
severity=ValidationSeverity.ERROR,
|
|
399
|
+
code="INVALID_INTERFACE_TYPE",
|
|
400
|
+
message=f"Interface at index {idx} must be an object",
|
|
401
|
+
path=f"agent_card.additionalInterfaces[{idx}]"
|
|
402
|
+
))
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
# Validate required interface fields
|
|
406
|
+
if "url" not in interface or not interface["url"]:
|
|
407
|
+
issues.append(ValidationIssue(
|
|
408
|
+
severity=ValidationSeverity.ERROR,
|
|
409
|
+
code="MISSING_INTERFACE_URL",
|
|
410
|
+
message=f"Interface at index {idx} missing URL",
|
|
411
|
+
path=f"agent_card.additionalInterfaces[{idx}].url"
|
|
412
|
+
))
|
|
413
|
+
else:
|
|
414
|
+
# Validate interface URL
|
|
415
|
+
url_result = self.url_validator.validate_url(interface["url"], field_name=f"agent_card.additionalInterfaces[{idx}].url", require_https=True)
|
|
416
|
+
issues.extend(url_result.issues)
|
|
417
|
+
|
|
418
|
+
if "transport" not in interface or not interface["transport"]:
|
|
419
|
+
issues.append(ValidationIssue(
|
|
420
|
+
severity=ValidationSeverity.ERROR,
|
|
421
|
+
code="MISSING_INTERFACE_TRANSPORT",
|
|
422
|
+
message=f"Interface at index {idx} missing transport",
|
|
423
|
+
path=f"agent_card.additionalInterfaces[{idx}].transport"
|
|
424
|
+
))
|
|
425
|
+
else:
|
|
426
|
+
# Validate transport
|
|
427
|
+
transport_issues = self._validate_transport(interface["transport"])
|
|
428
|
+
issues.extend(transport_issues)
|
|
429
|
+
|
|
430
|
+
# Check for transport conflicts on same URL
|
|
431
|
+
url = interface.get("url", "")
|
|
432
|
+
transport = interface["transport"]
|
|
433
|
+
if url and url in url_transport_map:
|
|
434
|
+
if url_transport_map[url] != transport:
|
|
435
|
+
issues.append(ValidationIssue(
|
|
436
|
+
severity=ValidationSeverity.ERROR,
|
|
437
|
+
code="TRANSPORT_URL_CONFLICT",
|
|
438
|
+
message=f"Conflicting transport protocols for URL {url}: {url_transport_map[url]} vs {transport}",
|
|
439
|
+
path=f"agent_card.additionalInterfaces[{idx}]"
|
|
440
|
+
))
|
|
441
|
+
elif url:
|
|
442
|
+
url_transport_map[url] = transport
|
|
443
|
+
|
|
444
|
+
return issues
|