agentmesh-platform 1.0.0a1__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,319 @@
1
+ """
2
+ Agent Identity
3
+
4
+ Every agent gets a unique, cryptographically bound identity issued by AgentMesh CA.
5
+ Identity persists across restarts; revocation propagates in ≤5s.
6
+ """
7
+
8
+ from datetime import datetime
9
+ from typing import Optional, Literal
10
+ from pydantic import BaseModel, Field, field_validator
11
+ from cryptography.hazmat.primitives import hashes
12
+ from cryptography.hazmat.primitives.asymmetric import ed25519
13
+ from cryptography.hazmat.primitives import serialization
14
+ import hashlib
15
+ import uuid
16
+ import base64
17
+
18
+
19
+ class AgentDID(BaseModel):
20
+ """
21
+ Decentralized Identifier for an agent.
22
+
23
+ Format: did:mesh:<unique-id>
24
+ """
25
+
26
+ method: Literal["mesh"] = "mesh"
27
+ unique_id: str = Field(..., description="Unique identifier within the mesh")
28
+
29
+ @classmethod
30
+ def generate(cls, name: str, org: Optional[str] = None) -> "AgentDID":
31
+ """Generate a new DID for an agent."""
32
+ # Create deterministic but unique ID
33
+ seed = f"{name}:{org or 'default'}:{uuid.uuid4().hex[:8]}"
34
+ unique_id = hashlib.sha256(seed.encode()).hexdigest()[:32]
35
+ return cls(unique_id=unique_id)
36
+
37
+ @classmethod
38
+ def from_string(cls, did_string: str) -> "AgentDID":
39
+ """Parse a DID string."""
40
+ if not did_string.startswith("did:mesh:"):
41
+ raise ValueError(f"Invalid AgentMesh DID: {did_string}")
42
+ unique_id = did_string[9:] # Remove "did:mesh:"
43
+ return cls(unique_id=unique_id)
44
+
45
+ def __str__(self) -> str:
46
+ return f"did:{self.method}:{self.unique_id}"
47
+
48
+ def __hash__(self) -> int:
49
+ return hash(str(self))
50
+
51
+
52
+ class AgentIdentity(BaseModel):
53
+ """
54
+ First-class identity for an AI agent.
55
+
56
+ Unlike service accounts, agent identities:
57
+ - Are linked to a human sponsor
58
+ - Have ephemeral credentials
59
+ - Support delegation chains
60
+ - Are continuously risk-scored
61
+ """
62
+
63
+ did: AgentDID = Field(..., description="Decentralized identifier")
64
+ name: str = Field(..., description="Human-readable agent name")
65
+ description: Optional[str] = Field(None, description="Agent description")
66
+
67
+ # Cryptographic identity
68
+ public_key: str = Field(..., description="Ed25519 public key (base64)")
69
+ verification_key_id: str = Field(..., description="Key ID for verification")
70
+
71
+ # Human sponsor (accountability)
72
+ sponsor_email: str = Field(..., description="Human sponsor email")
73
+ sponsor_verified: bool = Field(default=False, description="Whether sponsor is verified")
74
+
75
+ # Organization
76
+ organization: Optional[str] = Field(None, description="Organization name")
77
+ organization_id: Optional[str] = Field(None, description="Organization identifier")
78
+
79
+ # Capabilities (what this agent is allowed to do)
80
+ capabilities: list[str] = Field(default_factory=list)
81
+
82
+ # Metadata
83
+ created_at: datetime = Field(default_factory=datetime.utcnow)
84
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
85
+ expires_at: Optional[datetime] = Field(None, description="Identity expiration")
86
+
87
+ # Status
88
+ status: Literal["active", "suspended", "revoked"] = Field(default="active")
89
+ revocation_reason: Optional[str] = Field(None)
90
+
91
+ # Delegation
92
+ parent_did: Optional[str] = Field(None, description="Parent agent DID if delegated")
93
+ delegation_depth: int = Field(default=0, description="Depth in delegation chain")
94
+
95
+ # Private key stored separately (not serialized)
96
+ _private_key: Optional[ed25519.Ed25519PrivateKey] = None
97
+
98
+ model_config = {"arbitrary_types_allowed": True}
99
+
100
+ @classmethod
101
+ def create(
102
+ cls,
103
+ name: str,
104
+ sponsor: str,
105
+ capabilities: Optional[list[str]] = None,
106
+ organization: Optional[str] = None,
107
+ description: Optional[str] = None,
108
+ ) -> "AgentIdentity":
109
+ """
110
+ Create a new agent identity.
111
+
112
+ This is the primary factory method for creating governed agents.
113
+ """
114
+ # Generate keypair
115
+ private_key = ed25519.Ed25519PrivateKey.generate()
116
+ public_key = private_key.public_key()
117
+
118
+ # Encode public key
119
+ public_key_bytes = public_key.public_bytes(
120
+ encoding=serialization.Encoding.Raw,
121
+ format=serialization.PublicFormat.Raw,
122
+ )
123
+ public_key_b64 = base64.b64encode(public_key_bytes).decode()
124
+
125
+ # Generate DID
126
+ did = AgentDID.generate(name, organization)
127
+
128
+ # Create key ID
129
+ key_id = f"key-{hashlib.sha256(public_key_bytes).hexdigest()[:16]}"
130
+
131
+ identity = cls(
132
+ did=did,
133
+ name=name,
134
+ description=description,
135
+ public_key=public_key_b64,
136
+ verification_key_id=key_id,
137
+ sponsor_email=sponsor,
138
+ organization=organization,
139
+ capabilities=capabilities or [],
140
+ )
141
+
142
+ # Store private key (not serialized)
143
+ identity._private_key = private_key
144
+
145
+ return identity
146
+
147
+ def sign(self, data: bytes) -> str:
148
+ """Sign data with this agent's private key."""
149
+ if self._private_key is None:
150
+ raise ValueError("Private key not available for signing")
151
+
152
+ signature = self._private_key.sign(data)
153
+ return base64.b64encode(signature).decode()
154
+
155
+ def verify_signature(self, data: bytes, signature: str) -> bool:
156
+ """Verify a signature against this agent's public key."""
157
+ try:
158
+ public_key_bytes = base64.b64decode(self.public_key)
159
+ public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key_bytes)
160
+ signature_bytes = base64.b64decode(signature)
161
+ public_key.verify(signature_bytes, data)
162
+ return True
163
+ except Exception:
164
+ return False
165
+
166
+ def delegate(
167
+ self,
168
+ name: str,
169
+ capabilities: list[str],
170
+ description: Optional[str] = None,
171
+ ) -> "AgentIdentity":
172
+ """
173
+ Delegate to a child agent with narrowed capabilities.
174
+
175
+ The child agent's capabilities MUST be a subset of the parent's.
176
+ This is enforced cryptographically - delegation chains can only narrow.
177
+ """
178
+ # Validate capabilities are a subset
179
+ for cap in capabilities:
180
+ if cap not in self.capabilities:
181
+ raise ValueError(
182
+ f"Cannot delegate capability '{cap}' - not in parent's capabilities"
183
+ )
184
+
185
+ # Create child identity
186
+ child = AgentIdentity.create(
187
+ name=name,
188
+ sponsor=self.sponsor_email,
189
+ capabilities=capabilities,
190
+ organization=self.organization,
191
+ description=description,
192
+ )
193
+
194
+ # Set delegation metadata
195
+ child.parent_did = str(self.did)
196
+ child.delegation_depth = self.delegation_depth + 1
197
+
198
+ return child
199
+
200
+ def revoke(self, reason: str) -> None:
201
+ """Revoke this identity."""
202
+ self.status = "revoked"
203
+ self.revocation_reason = reason
204
+ self.updated_at = datetime.utcnow()
205
+
206
+ def suspend(self, reason: str) -> None:
207
+ """Temporarily suspend this identity."""
208
+ self.status = "suspended"
209
+ self.revocation_reason = reason
210
+ self.updated_at = datetime.utcnow()
211
+
212
+ def reactivate(self) -> None:
213
+ """Reactivate a suspended identity."""
214
+ if self.status == "revoked":
215
+ raise ValueError("Cannot reactivate a revoked identity")
216
+ self.status = "active"
217
+ self.revocation_reason = None
218
+ self.updated_at = datetime.utcnow()
219
+
220
+ def is_active(self) -> bool:
221
+ """Check if identity is active and not expired."""
222
+ if self.status != "active":
223
+ return False
224
+ if self.expires_at and datetime.utcnow() > self.expires_at:
225
+ return False
226
+ return True
227
+
228
+ def has_capability(self, capability: str) -> bool:
229
+ """Check if this agent has a specific capability."""
230
+ # Support wildcard matching
231
+ for cap in self.capabilities:
232
+ if cap == "*":
233
+ return True
234
+ if cap == capability:
235
+ return True
236
+ # Support prefix matching (e.g., "read:*" matches "read:data")
237
+ if cap.endswith(":*"):
238
+ prefix = cap[:-2]
239
+ if capability.startswith(prefix + ":"):
240
+ return True
241
+ return False
242
+
243
+ def to_did_document(self) -> dict:
244
+ """Export as a DID Document (W3C format)."""
245
+ return {
246
+ "@context": ["https://www.w3.org/ns/did/v1"],
247
+ "id": str(self.did),
248
+ "verificationMethod": [
249
+ {
250
+ "id": f"{self.did}#{self.verification_key_id}",
251
+ "type": "Ed25519VerificationKey2020",
252
+ "controller": str(self.did),
253
+ "publicKeyBase64": self.public_key,
254
+ }
255
+ ],
256
+ "authentication": [f"{self.did}#{self.verification_key_id}"],
257
+ "service": [
258
+ {
259
+ "id": f"{self.did}#agentmesh",
260
+ "type": "AgentMeshIdentity",
261
+ "serviceEndpoint": "https://mesh.agentmesh.dev/v1",
262
+ }
263
+ ],
264
+ }
265
+
266
+
267
+ class IdentityRegistry:
268
+ """
269
+ Registry for agent identities.
270
+
271
+ In production, this would be backed by a database and the AgentMesh CA.
272
+ """
273
+
274
+ def __init__(self):
275
+ self._identities: dict[str, AgentIdentity] = {}
276
+ self._by_sponsor: dict[str, list[str]] = {} # sponsor -> list of DIDs
277
+
278
+ def register(self, identity: AgentIdentity) -> None:
279
+ """Register an identity."""
280
+ did_str = str(identity.did)
281
+
282
+ if did_str in self._identities:
283
+ raise ValueError(f"Identity already registered: {did_str}")
284
+
285
+ self._identities[did_str] = identity
286
+
287
+ # Index by sponsor
288
+ if identity.sponsor_email not in self._by_sponsor:
289
+ self._by_sponsor[identity.sponsor_email] = []
290
+ self._by_sponsor[identity.sponsor_email].append(did_str)
291
+
292
+ def get(self, did: str | AgentDID) -> Optional[AgentIdentity]:
293
+ """Get an identity by DID."""
294
+ did_str = str(did) if isinstance(did, AgentDID) else did
295
+ return self._identities.get(did_str)
296
+
297
+ def revoke(self, did: str | AgentDID, reason: str) -> bool:
298
+ """Revoke an identity and all its delegates."""
299
+ identity = self.get(did)
300
+ if not identity:
301
+ return False
302
+
303
+ identity.revoke(reason)
304
+
305
+ # Revoke all children
306
+ for child_did, child in self._identities.items():
307
+ if child.parent_did == str(did):
308
+ self.revoke(child_did, f"Parent revoked: {reason}")
309
+
310
+ return True
311
+
312
+ def get_by_sponsor(self, sponsor_email: str) -> list[AgentIdentity]:
313
+ """Get all identities for a sponsor."""
314
+ dids = self._by_sponsor.get(sponsor_email, [])
315
+ return [self._identities[did] for did in dids if did in self._identities]
316
+
317
+ def list_active(self) -> list[AgentIdentity]:
318
+ """List all active identities."""
319
+ return [i for i in self._identities.values() if i.is_active()]
@@ -0,0 +1,323 @@
1
+ """
2
+ Ephemeral Credentials
3
+
4
+ Credentials with configurable TTL (default 15 min).
5
+ Expired credentials are rejected; rotation is automatic and zero-downtime.
6
+ """
7
+
8
+ from datetime import datetime, timedelta
9
+ from typing import Optional, Literal
10
+ from pydantic import BaseModel, Field
11
+ from cryptography.hazmat.primitives.asymmetric import ed25519
12
+ from cryptography.hazmat.primitives import serialization
13
+ import hashlib
14
+ import uuid
15
+ import base64
16
+ import secrets
17
+
18
+
19
+ class Credential(BaseModel):
20
+ """
21
+ Short-lived credential for agent authentication.
22
+
23
+ Unlike long-lived service account keys:
24
+ - Default TTL is 15 minutes
25
+ - Auto-rotates before expiration
26
+ - Instantly revocable
27
+ - Capability-scoped
28
+ """
29
+
30
+ credential_id: str = Field(..., description="Unique credential identifier")
31
+ agent_did: str = Field(..., description="DID of the agent this credential belongs to")
32
+
33
+ # Token
34
+ token: str = Field(..., description="Bearer token")
35
+ token_hash: str = Field(..., description="SHA-256 hash of token for verification")
36
+
37
+ # Scope
38
+ capabilities: list[str] = Field(default_factory=list, description="Scoped capabilities")
39
+ resources: list[str] = Field(default_factory=list, description="Accessible resources")
40
+
41
+ # Timing
42
+ issued_at: datetime = Field(default_factory=datetime.utcnow)
43
+ expires_at: datetime = Field(..., description="When credential expires")
44
+ ttl_seconds: int = Field(default=900, description="TTL in seconds (default 15 min)")
45
+
46
+ # Status
47
+ status: Literal["active", "rotated", "revoked", "expired"] = Field(default="active")
48
+ revoked_at: Optional[datetime] = Field(None)
49
+ revocation_reason: Optional[str] = Field(None)
50
+
51
+ # Rotation
52
+ previous_credential_id: Optional[str] = Field(None, description="Previous credential if rotated")
53
+ rotation_count: int = Field(default=0)
54
+
55
+ # Context
56
+ issued_for: Optional[str] = Field(None, description="Purpose/context for issuance")
57
+ client_ip: Optional[str] = Field(None, description="IP address of requester")
58
+
59
+ @classmethod
60
+ def issue(
61
+ cls,
62
+ agent_did: str,
63
+ capabilities: Optional[list[str]] = None,
64
+ resources: Optional[list[str]] = None,
65
+ ttl_seconds: int = 900, # 15 minutes default
66
+ issued_for: Optional[str] = None,
67
+ ) -> "Credential":
68
+ """
69
+ Issue a new credential for an agent.
70
+
71
+ Args:
72
+ agent_did: The agent's DID
73
+ capabilities: Scoped capabilities (subset of agent's capabilities)
74
+ resources: Specific resources this credential can access
75
+ ttl_seconds: Time-to-live in seconds (default 15 min)
76
+ issued_for: Context/purpose for the credential
77
+ """
78
+ # Generate secure token
79
+ token = secrets.token_urlsafe(32)
80
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
81
+
82
+ # Generate credential ID
83
+ credential_id = f"cred_{uuid.uuid4().hex[:16]}"
84
+
85
+ now = datetime.utcnow()
86
+
87
+ return cls(
88
+ credential_id=credential_id,
89
+ agent_did=agent_did,
90
+ token=token,
91
+ token_hash=token_hash,
92
+ capabilities=capabilities or [],
93
+ resources=resources or [],
94
+ issued_at=now,
95
+ expires_at=now + timedelta(seconds=ttl_seconds),
96
+ ttl_seconds=ttl_seconds,
97
+ issued_for=issued_for,
98
+ )
99
+
100
+ def is_valid(self) -> bool:
101
+ """Check if credential is valid (active and not expired)."""
102
+ if self.status != "active":
103
+ return False
104
+ return datetime.utcnow() < self.expires_at
105
+
106
+ def is_expiring_soon(self, threshold_seconds: int = 60) -> bool:
107
+ """Check if credential is about to expire."""
108
+ return datetime.utcnow() > (self.expires_at - timedelta(seconds=threshold_seconds))
109
+
110
+ def verify_token(self, token: str) -> bool:
111
+ """Verify a token matches this credential."""
112
+ return hashlib.sha256(token.encode()).hexdigest() == self.token_hash
113
+
114
+ def revoke(self, reason: str) -> None:
115
+ """Revoke this credential immediately."""
116
+ self.status = "revoked"
117
+ self.revoked_at = datetime.utcnow()
118
+ self.revocation_reason = reason
119
+
120
+ def rotate(self) -> "Credential":
121
+ """
122
+ Rotate this credential, creating a new one.
123
+
124
+ The old credential is marked as rotated but remains valid
125
+ for a brief overlap period to allow zero-downtime rotation.
126
+ """
127
+ # Mark current as rotated
128
+ self.status = "rotated"
129
+
130
+ # Create new credential
131
+ new_cred = Credential.issue(
132
+ agent_did=self.agent_did,
133
+ capabilities=self.capabilities,
134
+ resources=self.resources,
135
+ ttl_seconds=self.ttl_seconds,
136
+ issued_for=self.issued_for,
137
+ )
138
+
139
+ new_cred.previous_credential_id = self.credential_id
140
+ new_cred.rotation_count = self.rotation_count + 1
141
+
142
+ return new_cred
143
+
144
+ def has_capability(self, capability: str) -> bool:
145
+ """Check if this credential has a specific capability."""
146
+ if not self.capabilities:
147
+ return False
148
+
149
+ for cap in self.capabilities:
150
+ if cap == "*":
151
+ return True
152
+ if cap == capability:
153
+ return True
154
+ if cap.endswith(":*"):
155
+ prefix = cap[:-2]
156
+ if capability.startswith(prefix + ":"):
157
+ return True
158
+ return False
159
+
160
+ def can_access_resource(self, resource: str) -> bool:
161
+ """Check if this credential can access a specific resource."""
162
+ if not self.resources:
163
+ return True # No resource restrictions
164
+
165
+ return resource in self.resources or "*" in self.resources
166
+
167
+ def time_remaining(self) -> timedelta:
168
+ """Get time remaining until expiration."""
169
+ return max(timedelta(0), self.expires_at - datetime.utcnow())
170
+
171
+ def to_bearer_token(self) -> str:
172
+ """Get the bearer token for Authorization header."""
173
+ return f"Bearer {self.token}"
174
+
175
+
176
+ class CredentialManager:
177
+ """
178
+ Manages credential lifecycle.
179
+
180
+ Handles:
181
+ - Credential issuance
182
+ - Validation
183
+ - Rotation
184
+ - Revocation propagation
185
+ """
186
+
187
+ DEFAULT_TTL = 900 # 15 minutes
188
+ ROTATION_THRESHOLD = 60 # Start rotation 1 minute before expiry
189
+ REVOCATION_PROPAGATION_TARGET = 5 # Target: propagate in ≤5 seconds
190
+
191
+ def __init__(self, default_ttl: int = DEFAULT_TTL):
192
+ self.default_ttl = default_ttl
193
+ self._credentials: dict[str, Credential] = {}
194
+ self._by_agent: dict[str, list[str]] = {} # agent_did -> list of credential_ids
195
+ self._revocation_callbacks: list[callable] = []
196
+
197
+ def issue(
198
+ self,
199
+ agent_did: str,
200
+ capabilities: Optional[list[str]] = None,
201
+ resources: Optional[list[str]] = None,
202
+ ttl_seconds: Optional[int] = None,
203
+ issued_for: Optional[str] = None,
204
+ ) -> Credential:
205
+ """Issue a new credential."""
206
+ cred = Credential.issue(
207
+ agent_did=agent_did,
208
+ capabilities=capabilities,
209
+ resources=resources,
210
+ ttl_seconds=ttl_seconds or self.default_ttl,
211
+ issued_for=issued_for,
212
+ )
213
+
214
+ self._store(cred)
215
+ return cred
216
+
217
+ def validate(self, token: str) -> Optional[Credential]:
218
+ """
219
+ Validate a token and return the credential if valid.
220
+
221
+ Returns None if token is invalid, expired, or revoked.
222
+ """
223
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
224
+
225
+ for cred in self._credentials.values():
226
+ if cred.token_hash == token_hash:
227
+ if cred.is_valid():
228
+ return cred
229
+ return None # Found but invalid
230
+
231
+ return None # Not found
232
+
233
+ def rotate(self, credential_id: str) -> Optional[Credential]:
234
+ """Rotate a credential."""
235
+ cred = self._credentials.get(credential_id)
236
+ if not cred or not cred.is_valid():
237
+ return None
238
+
239
+ new_cred = cred.rotate()
240
+ self._store(new_cred)
241
+
242
+ return new_cred
243
+
244
+ def rotate_if_needed(self, credential_id: str) -> Credential:
245
+ """Rotate credential if it's expiring soon."""
246
+ cred = self._credentials.get(credential_id)
247
+ if not cred:
248
+ raise ValueError(f"Credential not found: {credential_id}")
249
+
250
+ if cred.is_expiring_soon(self.ROTATION_THRESHOLD):
251
+ return self.rotate(credential_id)
252
+
253
+ return cred
254
+
255
+ def revoke(self, credential_id: str, reason: str) -> bool:
256
+ """
257
+ Revoke a credential.
258
+
259
+ Propagation target: ≤5 seconds to all systems.
260
+ """
261
+ cred = self._credentials.get(credential_id)
262
+ if not cred:
263
+ return False
264
+
265
+ cred.revoke(reason)
266
+
267
+ # Trigger revocation callbacks
268
+ for callback in self._revocation_callbacks:
269
+ try:
270
+ callback(cred)
271
+ except Exception:
272
+ pass # Don't let callback failures block revocation
273
+
274
+ return True
275
+
276
+ def revoke_all_for_agent(self, agent_did: str, reason: str) -> int:
277
+ """Revoke all credentials for an agent."""
278
+ count = 0
279
+ cred_ids = self._by_agent.get(agent_did, [])
280
+
281
+ for cred_id in cred_ids:
282
+ if self.revoke(cred_id, reason):
283
+ count += 1
284
+
285
+ return count
286
+
287
+ def get_active_for_agent(self, agent_did: str) -> list[Credential]:
288
+ """Get all active credentials for an agent."""
289
+ cred_ids = self._by_agent.get(agent_did, [])
290
+ return [
291
+ self._credentials[cid]
292
+ for cid in cred_ids
293
+ if cid in self._credentials and self._credentials[cid].is_valid()
294
+ ]
295
+
296
+ def cleanup_expired(self) -> int:
297
+ """Remove expired credentials from memory."""
298
+ expired = [
299
+ cid for cid, cred in self._credentials.items()
300
+ if not cred.is_valid() and cred.status != "active"
301
+ ]
302
+
303
+ for cid in expired:
304
+ cred = self._credentials.pop(cid, None)
305
+ if cred:
306
+ # Remove from agent index
307
+ agent_creds = self._by_agent.get(cred.agent_did, [])
308
+ if cid in agent_creds:
309
+ agent_creds.remove(cid)
310
+
311
+ return len(expired)
312
+
313
+ def on_revocation(self, callback: callable) -> None:
314
+ """Register a callback for revocation events."""
315
+ self._revocation_callbacks.append(callback)
316
+
317
+ def _store(self, cred: Credential) -> None:
318
+ """Store a credential in the index."""
319
+ self._credentials[cred.credential_id] = cred
320
+
321
+ if cred.agent_did not in self._by_agent:
322
+ self._by_agent[cred.agent_did] = []
323
+ self._by_agent[cred.agent_did].append(cred.credential_id)