capiscio-sdk 0.2.0__py3-none-any.whl → 2.3.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,304 @@
1
+ """BadgeKeeper - Automatic badge renewal for continuous operation.
2
+
3
+ BadgeKeeper monitors badge expiration and automatically renews badges
4
+ before they expire, ensuring uninterrupted agent authentication.
5
+
6
+ Example:
7
+ >>> from capiscio_sdk import BadgeKeeper, SimpleGuard
8
+ >>>
9
+ >>> # Basic usage
10
+ >>> keeper = BadgeKeeper(
11
+ ... api_url="https://registry.capisc.io",
12
+ ... api_key="your-api-key",
13
+ ... agent_id="your-agent-id",
14
+ ... renewal_threshold=10, # Renew 10s before expiry
15
+ ... )
16
+ >>> keeper.start()
17
+ >>> # Badge automatically renews in background
18
+ >>> current_badge = keeper.get_current_badge()
19
+ >>> keeper.stop()
20
+ >>>
21
+ >>> # Integration with SimpleGuard
22
+ >>> guard = SimpleGuard()
23
+ >>> keeper = BadgeKeeper(
24
+ ... api_url="https://registry.capisc.io",
25
+ ... api_key="your-api-key",
26
+ ... agent_id="your-agent-id",
27
+ ... on_renew=lambda token: guard.set_badge_token(token),
28
+ ... )
29
+ >>> keeper.start()
30
+ """
31
+
32
+ import logging
33
+ import threading
34
+ from dataclasses import dataclass
35
+ from datetime import datetime, timezone
36
+ from typing import Optional, Callable, Dict, Any
37
+
38
+ from capiscio_sdk._rpc.client import CapiscioRPCClient
39
+ from capiscio_sdk.errors import CapiscioSecurityError
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ @dataclass
45
+ class BadgeKeeperConfig:
46
+ """Configuration for BadgeKeeper.
47
+
48
+ Attributes:
49
+ api_url: Badge CA URL (e.g., https://registry.capisc.io)
50
+ api_key: API key for badge issuance
51
+ agent_id: Agent identifier (UUID or DID)
52
+ mode: Keeper mode ('ca' or 'pop', default: 'ca')
53
+ output_file: Path to write badge file (default: badge.jwt)
54
+ ttl_seconds: Badge TTL in seconds (default: 300 = 5 minutes)
55
+ renewal_threshold: Renew this many seconds before expiry (default: 10)
56
+ check_interval: Check interval in seconds (default: 5)
57
+ trust_level: Trust level for CA mode (1-4, default: 1)
58
+ rpc_address: Optional custom RPC address for capiscio-core
59
+ on_renew: Optional callback(token: str) called when badge renews
60
+ max_retries: Max retry attempts on renewal failure (default: 3)
61
+ retry_backoff: Base backoff seconds for exponential retry (default: 2)
62
+ """
63
+ api_url: str
64
+ api_key: str
65
+ agent_id: str
66
+ mode: str = "ca"
67
+ output_file: str = "badge.jwt"
68
+ ttl_seconds: int = 300
69
+ renewal_threshold: int = 10
70
+ check_interval: int = 5
71
+ trust_level: int = 1
72
+ rpc_address: Optional[str] = None
73
+ on_renew: Optional[Callable[[str], None]] = None
74
+ max_retries: int = 3
75
+ retry_backoff: int = 2
76
+
77
+
78
+ class BadgeKeeper:
79
+ """Automatic badge renewal manager.
80
+
81
+ BadgeKeeper runs a background thread that monitors badge expiration
82
+ and automatically requests new badges before they expire. This ensures
83
+ continuous agent authentication without manual intervention.
84
+
85
+ The keeper integrates with SimpleGuard via the on_renew callback,
86
+ allowing seamless badge token updates for outbound request signing.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ api_url: str,
92
+ api_key: str,
93
+ agent_id: str,
94
+ mode: str = "ca",
95
+ output_file: str = "badge.jwt",
96
+ ttl_seconds: int = 300,
97
+ renewal_threshold: int = 10,
98
+ check_interval: int = 5,
99
+ trust_level: int = 1,
100
+ rpc_address: Optional[str] = None,
101
+ on_renew: Optional[Callable[[str], None]] = None,
102
+ max_retries: int = 3,
103
+ retry_backoff: int = 2,
104
+ ):
105
+ """Initialize BadgeKeeper.
106
+
107
+ Args:
108
+ api_url: Badge CA URL (e.g., https://registry.capisc.io)
109
+ api_key: API key for badge issuance
110
+ agent_id: Agent identifier (UUID or DID)
111
+ mode: Keeper mode ('ca' or 'pop', default: 'ca')
112
+ output_file: Path to write badge file (default: badge.jwt)
113
+ ttl_seconds: Badge TTL in seconds (default: 300 = 5 minutes)
114
+ renewal_threshold: Renew this many seconds before expiry (default: 10)
115
+ check_interval: Check interval in seconds (default: 5)
116
+ trust_level: Trust level for CA mode (1-4, default: 1)
117
+ rpc_address: Optional custom RPC address for capiscio-core
118
+ on_renew: Optional callback(token: str) called when badge renews
119
+ max_retries: Max retry attempts on renewal failure (default: 3)
120
+ retry_backoff: Base backoff seconds for exponential retry (default: 2)
121
+ """
122
+ self.config = BadgeKeeperConfig(
123
+ api_url=api_url,
124
+ api_key=api_key,
125
+ agent_id=agent_id,
126
+ mode=mode,
127
+ output_file=output_file,
128
+ ttl_seconds=ttl_seconds,
129
+ renewal_threshold=renewal_threshold,
130
+ check_interval=check_interval,
131
+ trust_level=trust_level,
132
+ rpc_address=rpc_address,
133
+ on_renew=on_renew,
134
+ max_retries=max_retries,
135
+ retry_backoff=retry_backoff,
136
+ )
137
+
138
+ self._rpc_client: Optional[CapiscioRPCClient] = None
139
+ self._thread: Optional[threading.Thread] = None
140
+ self._stop_event = threading.Event()
141
+ self._current_badge: Optional[str] = None
142
+ self._badge_lock = threading.Lock()
143
+ self._running = False
144
+
145
+ def start(self) -> None:
146
+ """Start the badge keeper background thread.
147
+
148
+ Raises:
149
+ CapiscioSecurityError: If keeper is already running
150
+ """
151
+ if self._running:
152
+ raise CapiscioSecurityError("BadgeKeeper is already running")
153
+
154
+ logger.info(
155
+ f"Starting BadgeKeeper (mode={self.config.mode}, "
156
+ f"threshold={self.config.renewal_threshold}s)"
157
+ )
158
+
159
+ self._stop_event.clear()
160
+ self._running = True
161
+ self._thread = threading.Thread(target=self._run_keeper, daemon=True)
162
+ self._thread.start()
163
+
164
+ def stop(self) -> None:
165
+ """Stop the badge keeper background thread."""
166
+ if not self._running:
167
+ return
168
+
169
+ logger.info("Stopping BadgeKeeper...")
170
+ self._stop_event.set()
171
+
172
+ if self._thread:
173
+ self._thread.join(timeout=5)
174
+ self._thread = None
175
+
176
+ if self._rpc_client:
177
+ self._rpc_client.close()
178
+ self._rpc_client = None
179
+
180
+ self._running = False
181
+ logger.info("BadgeKeeper stopped")
182
+
183
+ def get_current_badge(self) -> Optional[str]:
184
+ """Get the current badge token.
185
+
186
+ Returns:
187
+ Current badge token, or None if no badge yet
188
+ """
189
+ with self._badge_lock:
190
+ return self._current_badge
191
+
192
+ def is_running(self) -> bool:
193
+ """Check if keeper is running.
194
+
195
+ Returns:
196
+ True if keeper is running, False otherwise
197
+ """
198
+ return self._running
199
+
200
+ def _run_keeper(self) -> None:
201
+ """Background thread that runs the keeper loop."""
202
+ try:
203
+ # Initialize RPC client
204
+ self._rpc_client = CapiscioRPCClient(
205
+ address=self.config.rpc_address or "unix:///tmp/capiscio.sock"
206
+ )
207
+
208
+ logger.debug("BadgeKeeper thread started, streaming events from core...")
209
+
210
+ # Stream events from capiscio-core keeper
211
+ for event in self._rpc_client.badge.start_keeper(
212
+ mode=self.config.mode,
213
+ output_file=self.config.output_file,
214
+ agent_id=self.config.agent_id,
215
+ api_key=self.config.api_key,
216
+ ca_url=self.config.api_url,
217
+ ttl_seconds=self.config.ttl_seconds,
218
+ renew_before_seconds=self.config.renewal_threshold,
219
+ check_interval_seconds=self.config.check_interval,
220
+ trust_level=self.config.trust_level,
221
+ ):
222
+ # Check stop signal
223
+ if self._stop_event.is_set():
224
+ logger.debug("Stop event detected, exiting keeper loop")
225
+ break
226
+
227
+ self._handle_keeper_event(event)
228
+
229
+ logger.debug("Keeper event stream ended")
230
+
231
+ except Exception as e:
232
+ logger.error(f"BadgeKeeper error: {e}", exc_info=True)
233
+ self._running = False
234
+ finally:
235
+ if self._rpc_client:
236
+ self._rpc_client.close()
237
+ self._rpc_client = None
238
+
239
+ def _handle_keeper_event(self, event: Dict[str, Any]) -> None:
240
+ """Handle a keeper event from the stream.
241
+
242
+ Args:
243
+ event: Event dict from start_keeper stream
244
+ """
245
+ event_type = event.get("type")
246
+
247
+ if event_type == "started":
248
+ logger.info(
249
+ f"BadgeKeeper started for agent {self.config.agent_id}"
250
+ )
251
+
252
+ elif event_type == "renewed":
253
+ badge_token = event.get("token")
254
+ badge_jti = event.get("badge_jti", "unknown")
255
+ expires_ts = event.get("expires_at", 0)
256
+
257
+ if badge_token:
258
+ with self._badge_lock:
259
+ self._current_badge = badge_token
260
+
261
+ # Call renewal callback if configured
262
+ if self.config.on_renew:
263
+ try:
264
+ self.config.on_renew(badge_token)
265
+ logger.debug("Called on_renew callback")
266
+ except Exception as e:
267
+ logger.error(f"Error in on_renew callback: {e}")
268
+
269
+ # Format expiry time
270
+ if expires_ts > 0:
271
+ expires_dt = datetime.fromtimestamp(expires_ts, timezone.utc)
272
+ logger.info(
273
+ f"Badge renewed (jti={badge_jti[:8]}..., "
274
+ f"expires={expires_dt.isoformat()})"
275
+ )
276
+ else:
277
+ logger.info(f"Badge renewed (jti={badge_jti[:8]}...)")
278
+ else:
279
+ logger.warning("Renewed event but no token in response")
280
+
281
+ elif event_type == "error":
282
+ error_msg = event.get("error", "Unknown error")
283
+ error_code = event.get("error_code", "")
284
+ logger.error(
285
+ f"BadgeKeeper error: {error_msg} "
286
+ f"(code={error_code})"
287
+ )
288
+
289
+ elif event_type == "stopped":
290
+ logger.info("BadgeKeeper stopped by core")
291
+ self._stop_event.set()
292
+
293
+ else:
294
+ logger.debug(f"Unknown keeper event type: {event_type}")
295
+
296
+ def __enter__(self):
297
+ """Context manager entry."""
298
+ self.start()
299
+ return self
300
+
301
+ def __exit__(self, exc_type, exc_val, exc_tb):
302
+ """Context manager exit."""
303
+ self.stop()
304
+ return False
capiscio_sdk/config.py CHANGED
@@ -1,4 +1,4 @@
1
- """Configuration for Capiscio A2A Security."""
1
+ """Configuration for Capiscio Python SDK."""
2
2
  import os
3
3
  from typing import Literal
4
4
  from pydantic import BaseModel, Field
capiscio_sdk/dv.py ADDED
@@ -0,0 +1,296 @@
1
+ """Domain Validation (DV) Badge API for CapiscIO agents.
2
+
3
+ This module provides high-level API for DV badge orders (RFC-002 v1.2).
4
+ DV badges provide Level 1 trust with domain ownership verification.
5
+
6
+ Uses the capiscio-core gRPC service for all operations, following the
7
+ established RPC pattern.
8
+
9
+ Example usage:
10
+
11
+ from capiscio_sdk.dv import create_dv_order, get_dv_order, finalize_dv_order
12
+ import json
13
+
14
+ # Load agent's public key
15
+ with open("public.jwk") as f:
16
+ jwk = json.load(f)
17
+
18
+ # Create a DV order
19
+ order = create_dv_order(
20
+ domain="example.com",
21
+ challenge_type="http-01",
22
+ jwk=jwk,
23
+ )
24
+
25
+ print(f"Order ID: {order.order_id}")
26
+ print(f"Place this token at: {order.validation_url}")
27
+ print(f"Token: {order.challenge_token}")
28
+
29
+ # After completing the challenge, finalize the order
30
+ grant = finalize_dv_order(order.order_id)
31
+ print(f"Grant JWT: {grant.grant}")
32
+
33
+ # Save the grant for later use
34
+ with open("grant.jwt", "w") as f:
35
+ f.write(grant.grant)
36
+ """
37
+
38
+ from dataclasses import dataclass
39
+ from datetime import datetime
40
+ from typing import Optional
41
+ import json
42
+
43
+ from capiscio_sdk._rpc.client import CapiscioRPCClient
44
+
45
+
46
+ @dataclass
47
+ class DVOrder:
48
+ """Domain Validation badge order.
49
+
50
+ Attributes:
51
+ order_id: Unique order identifier (UUID).
52
+ domain: Domain being validated.
53
+ challenge_type: Type of challenge (http-01 or dns-01).
54
+ challenge_token: Token to place for validation.
55
+ status: Order status (pending, valid, invalid, expired).
56
+ validation_url: URL where token should be placed (http-01).
57
+ dns_record: DNS record to create (dns-01).
58
+ expires_at: When the order expires.
59
+ finalized_at: When the order was finalized (if applicable).
60
+ """
61
+
62
+ order_id: str
63
+ domain: str
64
+ challenge_type: str
65
+ challenge_token: str
66
+ status: str
67
+ validation_url: str = ""
68
+ dns_record: str = ""
69
+ expires_at: Optional[datetime] = None
70
+ finalized_at: Optional[datetime] = None
71
+
72
+
73
+ @dataclass
74
+ class DVGrant:
75
+ """Domain Validation grant JWT.
76
+
77
+ Attributes:
78
+ grant: The signed grant JWT token.
79
+ expires_at: When the grant expires.
80
+ """
81
+
82
+ grant: str
83
+ expires_at: Optional[datetime] = None
84
+
85
+
86
+ # Module-level client (lazy initialization)
87
+ _client: Optional[CapiscioRPCClient] = None
88
+
89
+
90
+ def _get_client() -> CapiscioRPCClient:
91
+ """Get or create the module-level gRPC client."""
92
+ global _client
93
+ if _client is None:
94
+ _client = CapiscioRPCClient()
95
+ _client.connect()
96
+ return _client
97
+
98
+
99
+ def _parse_timestamp(ts: str) -> Optional[datetime]:
100
+ """Parse ISO 8601 timestamp string to datetime."""
101
+ if not ts:
102
+ return None
103
+ try:
104
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
105
+ except (ValueError, AttributeError):
106
+ return None
107
+
108
+
109
+ def create_dv_order(
110
+ domain: str,
111
+ challenge_type: str,
112
+ jwk: dict,
113
+ ca_url: str = "https://registry.capisc.io",
114
+ ) -> DVOrder:
115
+ """Create a new DV badge order.
116
+
117
+ Uses the capiscio-core gRPC service to create the order.
118
+
119
+ Args:
120
+ domain: Domain to validate (e.g., "example.com").
121
+ challenge_type: Challenge type ("http-01" or "dns-01").
122
+ jwk: Agent's public key in JWK format (dict).
123
+ ca_url: Certificate Authority URL.
124
+
125
+ Returns:
126
+ DVOrder with challenge details.
127
+
128
+ Raises:
129
+ ValueError: If the CA returns an error.
130
+
131
+ Example:
132
+ import json
133
+
134
+ with open("public.jwk") as f:
135
+ jwk = json.load(f)
136
+
137
+ order = create_dv_order(
138
+ domain="example.com",
139
+ challenge_type="http-01",
140
+ jwk=jwk,
141
+ )
142
+
143
+ print(f"Place token at: {order.validation_url}")
144
+ """
145
+ if challenge_type not in ("http-01", "dns-01"):
146
+ raise ValueError(
147
+ f"Invalid challenge_type: {challenge_type}. Must be 'http-01' or 'dns-01'"
148
+ )
149
+
150
+ try:
151
+ client = _get_client()
152
+
153
+ # Convert JWK dict to JSON string
154
+ jwk_str = json.dumps(jwk)
155
+
156
+ success, order_dict, error = client.badge.create_dv_order(
157
+ domain=domain,
158
+ challenge_type=challenge_type,
159
+ jwk=jwk_str,
160
+ ca_url=ca_url,
161
+ )
162
+
163
+ if not success:
164
+ raise ValueError(f"CA rejected DV order: {error}")
165
+
166
+ if not order_dict:
167
+ raise ValueError("CA response missing order data")
168
+
169
+ return DVOrder(
170
+ order_id=order_dict["order_id"],
171
+ domain=order_dict["domain"],
172
+ challenge_type=order_dict["challenge_type"],
173
+ challenge_token=order_dict["challenge_token"],
174
+ status=order_dict["status"],
175
+ validation_url=order_dict.get("validation_url", ""),
176
+ dns_record=order_dict.get("dns_record", ""),
177
+ expires_at=_parse_timestamp(order_dict.get("expires_at", "")),
178
+ )
179
+
180
+ except Exception as e:
181
+ if isinstance(e, ValueError):
182
+ raise
183
+ raise ValueError(f"Failed to create DV order: {e}") from e
184
+
185
+
186
+ def get_dv_order(
187
+ order_id: str,
188
+ ca_url: str = "https://registry.capisc.io",
189
+ ) -> DVOrder:
190
+ """Get the status of a DV badge order.
191
+
192
+ Uses the capiscio-core gRPC service to check order status.
193
+
194
+ Args:
195
+ order_id: Order ID from create_dv_order.
196
+ ca_url: Certificate Authority URL.
197
+
198
+ Returns:
199
+ DVOrder with current status.
200
+
201
+ Raises:
202
+ ValueError: If the CA returns an error.
203
+
204
+ Example:
205
+ order = get_dv_order("550e8400-e29b-41d4-a716-446655440000")
206
+ print(f"Status: {order.status}")
207
+ """
208
+ try:
209
+ client = _get_client()
210
+
211
+ success, order_dict, error = client.badge.get_dv_order(
212
+ order_id=order_id,
213
+ ca_url=ca_url,
214
+ )
215
+
216
+ if not success:
217
+ raise ValueError(f"Failed to get DV order: {error}")
218
+
219
+ if not order_dict:
220
+ raise ValueError("CA response missing order data")
221
+
222
+ return DVOrder(
223
+ order_id=order_dict["order_id"],
224
+ domain=order_dict["domain"],
225
+ challenge_type=order_dict["challenge_type"],
226
+ challenge_token=order_dict["challenge_token"],
227
+ status=order_dict["status"],
228
+ validation_url=order_dict.get("validation_url", ""),
229
+ dns_record=order_dict.get("dns_record", ""),
230
+ expires_at=_parse_timestamp(order_dict.get("expires_at", "")),
231
+ finalized_at=_parse_timestamp(order_dict.get("finalized_at", "")),
232
+ )
233
+
234
+ except Exception as e:
235
+ if isinstance(e, ValueError):
236
+ raise
237
+ raise ValueError(f"Failed to get DV order: {e}") from e
238
+
239
+
240
+ def finalize_dv_order(
241
+ order_id: str,
242
+ ca_url: str = "https://registry.capisc.io",
243
+ ) -> DVGrant:
244
+ """Finalize a DV badge order and receive a grant.
245
+
246
+ Uses the capiscio-core gRPC service to finalize the order.
247
+
248
+ Args:
249
+ order_id: Order ID from create_dv_order.
250
+ ca_url: Certificate Authority URL.
251
+
252
+ Returns:
253
+ DVGrant with the signed grant JWT.
254
+
255
+ Raises:
256
+ ValueError: If the CA returns an error.
257
+
258
+ Example:
259
+ grant = finalize_dv_order("550e8400-e29b-41d4-a716-446655440000")
260
+
261
+ # Save grant
262
+ with open("grant.jwt", "w") as f:
263
+ f.write(grant.grant)
264
+ """
265
+ try:
266
+ client = _get_client()
267
+
268
+ success, grant_dict, error = client.badge.finalize_dv_order(
269
+ order_id=order_id,
270
+ ca_url=ca_url,
271
+ )
272
+
273
+ if not success:
274
+ raise ValueError(f"Failed to finalize DV order: {error}")
275
+
276
+ if not grant_dict:
277
+ raise ValueError("CA response missing grant data")
278
+
279
+ return DVGrant(
280
+ grant=grant_dict["grant"],
281
+ expires_at=_parse_timestamp(grant_dict.get("expires_at", "")),
282
+ )
283
+
284
+ except Exception as e:
285
+ if isinstance(e, ValueError):
286
+ raise
287
+ raise ValueError(f"Failed to finalize DV order: {e}") from e
288
+
289
+
290
+ __all__ = [
291
+ "DVOrder",
292
+ "DVGrant",
293
+ "create_dv_order",
294
+ "get_dv_order",
295
+ "finalize_dv_order",
296
+ ]
capiscio_sdk/errors.py CHANGED
@@ -1,4 +1,4 @@
1
- """Error types for Capiscio A2A Security."""
1
+ """Error types for Capiscio Python SDK."""
2
2
  from typing import Optional, List, Dict, Any
3
3
  from .types import ValidationResult
4
4
 
@@ -67,3 +67,13 @@ class CapiscioTimeoutError(CapiscioSecurityError):
67
67
  """Operation timed out."""
68
68
 
69
69
  pass
70
+
71
+
72
+ class ConfigurationError(CapiscioSecurityError):
73
+ """Missing keys or invalid paths (SimpleGuard)."""
74
+ pass
75
+
76
+
77
+ class VerificationError(CapiscioSecurityError):
78
+ """Invalid signature, expired token, or untrusted key (SimpleGuard)."""
79
+ pass
capiscio_sdk/executor.py CHANGED
@@ -138,6 +138,23 @@ class CapiscioSecurityExecutor:
138
138
  # Cancellation just passes through - no security checks needed
139
139
  await self.delegate.cancel(context, event_queue)
140
140
 
141
+ async def validate_agent_card(self, url: str) -> ValidationResult:
142
+ """
143
+ Validate an agent card from a URL.
144
+
145
+ Uses CoreValidator which delegates to Go core for consistent
146
+ validation across all CapiscIO SDKs.
147
+
148
+ Args:
149
+ url: URL to the agent card or agent root
150
+
151
+ Returns:
152
+ ValidationResult with scores
153
+ """
154
+ from .validators import CoreValidator
155
+ with CoreValidator() as validator:
156
+ return await validator.fetch_and_validate(url)
157
+
141
158
  def _validate_message(self, message: Dict[str, Any]) -> ValidationResult:
142
159
  """Validate message with caching."""
143
160
  # Try cache first