capforge 0.4.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.
capforge/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """CapForge — Agent Capability Manifest (ACM) SDK.
2
+
3
+ CapForge is the reference Python implementation of the Agent Capability
4
+ Manifest (ACM) protocol. It provides a clean API for creating, signing,
5
+ verifying, delegating, saving, and loading ACM documents.
6
+
7
+ Basic usage::
8
+
9
+ from capforge import ACM, Capability, Constraints
10
+
11
+ # Create a new manifest
12
+ manifest = ACM.create(
13
+ spiffe_id="spiffe://org/agent/my-bot",
14
+ sponsor="user@org.com",
15
+ capabilities=[Capability(resource="kb", action="read")],
16
+ constraints=Constraints(max_cost_per_session=0.05),
17
+ )
18
+
19
+ # Sign, save, load, verify
20
+ manifest.sign("my-key.pem").save("manifest.json")
21
+ loaded = ACM.load("manifest.json")
22
+ loaded.verify(trusted_keys=["my-key.pub"])
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from capforge._acm import ACM
28
+ from capforge._exceptions import (
29
+ ACMError,
30
+ ACMExpiredError,
31
+ ACMInvalidError,
32
+ ACMSignatureError,
33
+ ACMValidationError,
34
+ )
35
+ from capforge._model import Capability, Constraints
36
+ from capforge._policy import PolicyDecision
37
+ from capforge._version import __version__
38
+
39
+ __all__: list[str] = [
40
+ "ACM",
41
+ "ACMError",
42
+ "ACMExpiredError",
43
+ "ACMInvalidError",
44
+ "ACMSignatureError",
45
+ "ACMValidationError",
46
+ "Capability",
47
+ "Constraints",
48
+ "PolicyDecision",
49
+ "__version__",
50
+ ]
capforge/_acm.py ADDED
@@ -0,0 +1,491 @@
1
+ """Main public API for the CapForge SDK.
2
+
3
+ The ``ACM`` class provides the primary interface for creating, signing,
4
+ verifying, delegating, saving, and loading Agent Capability Manifest
5
+ documents.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import UTC, datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
16
+
17
+ from capforge._crypto import (
18
+ load_private_key,
19
+ load_public_key,
20
+ sign_dict,
21
+ verify_dict,
22
+ )
23
+ from capforge._delegation import delegate as _delegate_model
24
+ from capforge._exceptions import ACMError, ACMSignatureError
25
+ from capforge._model import (
26
+ Capability,
27
+ Constraints,
28
+ _ACMModel,
29
+ _AgentInfo,
30
+ )
31
+ from capforge._policy import PolicyDecision, RuntimeEvaluator
32
+ from capforge._validator import validate as _validate
33
+
34
+
35
+ class ACM:
36
+ """Agent Capability Manifest — the primary interface for ACM documents.
37
+
38
+ Create, sign, verify, delegate, save, and load Agent Capability Manifest
39
+ documents.
40
+
41
+ Basic usage::
42
+
43
+ from capforge import ACM, Capability, Constraints
44
+
45
+ # Create a new manifest
46
+ manifest = ACM.create(
47
+ spiffe_id="spiffe://org/agent/my-bot",
48
+ sponsor="user@org.com",
49
+ capabilities=[Capability(resource="kb", action="read")],
50
+ )
51
+
52
+ # Sign and save it
53
+ manifest.sign("my-key.pem")
54
+ manifest.save("manifest.json")
55
+
56
+ # Load and verify
57
+ loaded = ACM.load("manifest.json")
58
+ loaded.verify()
59
+ """
60
+
61
+ def __init__(self, model: _ACMModel) -> None:
62
+ self._model = model
63
+
64
+ # ── Factory Methods ───────────────────────────────────────────
65
+
66
+ @classmethod
67
+ def create(
68
+ cls,
69
+ spiffe_id: str,
70
+ sponsor: str,
71
+ capabilities: list[Capability],
72
+ *,
73
+ issuer: str | None = None,
74
+ constraints: Constraints | None = None,
75
+ ttl: int = 3600,
76
+ not_before: datetime | None = None,
77
+ metadata: dict[str, Any] | None = None,
78
+ model: str | None = None,
79
+ provider: str | None = None,
80
+ ) -> ACM:
81
+ """Create a new ACM manifest.
82
+
83
+ Parameters
84
+ ----------
85
+ spiffe_id:
86
+ SPIFFE ID of the agent being authorized.
87
+ sponsor:
88
+ Email or SPIFFE ID of the responsible human or team.
89
+ capabilities:
90
+ List of capabilities to grant.
91
+ issuer:
92
+ SPIFFE ID of the issuing entity. Defaults to *spiffe_id*.
93
+ constraints:
94
+ Optional global constraints.
95
+ ttl:
96
+ Time-to-live in seconds (default 3600 = 1 hour).
97
+ not_before:
98
+ Optional ISO 8601 timestamp before which the manifest is invalid.
99
+ metadata:
100
+ Optional implementation-specific metadata.
101
+ model:
102
+ Optional LLM model name (e.g., ``"gpt-5"``).
103
+ provider:
104
+ Optional model provider (e.g., ``"openai"``).
105
+
106
+ Returns
107
+ -------
108
+ ACM
109
+ A new unsigned ACM manifest.
110
+ """
111
+ now = datetime.now(UTC)
112
+ acm_model = _ACMModel(
113
+ agent=_AgentInfo(
114
+ spiffe_id=spiffe_id,
115
+ model=model,
116
+ provider=provider,
117
+ ),
118
+ human_sponsor=sponsor,
119
+ capabilities=capabilities,
120
+ constraints=constraints,
121
+ issuer=issuer or spiffe_id,
122
+ expires_at=now + timedelta(seconds=ttl),
123
+ not_before=not_before or now,
124
+ metadata=metadata,
125
+ )
126
+ return cls(acm_model)
127
+
128
+ @classmethod
129
+ def load(cls, source: str | Path | dict[str, Any]) -> ACM:
130
+ """Load an ACM manifest from a JSON file or dictionary.
131
+
132
+ Parameters
133
+ ----------
134
+ source:
135
+ Path to a JSON file, or a dictionary representing the ACM.
136
+
137
+ Returns
138
+ -------
139
+ ACM
140
+ The loaded manifest.
141
+ """
142
+ if isinstance(source, (str, Path)):
143
+ path = Path(source)
144
+ with path.open() as f:
145
+ data = json.load(f)
146
+ else:
147
+ data = source
148
+
149
+ model = _ACMModel.model_validate(data)
150
+ return cls(model)
151
+
152
+ # ── Persistence ───────────────────────────────────────────────
153
+
154
+ def save(self, path: str | Path | None = None) -> str:
155
+ """Save this manifest to a JSON file and/or return its JSON string.
156
+
157
+ Parameters
158
+ ----------
159
+ path:
160
+ Optional file path. If provided, the JSON is written to this file.
161
+
162
+ Returns
163
+ -------
164
+ str
165
+ The pretty-printed JSON string of this manifest.
166
+ """
167
+ result = self._model.model_dump_json(indent=2)
168
+ if path is not None:
169
+ Path(path).write_text(result + "\n")
170
+ return result
171
+
172
+ def to_dict(self) -> dict[str, Any]:
173
+ """Return this manifest as a dictionary (JSON-safe)."""
174
+ return self._model.model_dump()
175
+
176
+ def to_json(self) -> str:
177
+ """Return this manifest as a pretty-printed JSON string."""
178
+ return self._model.model_dump_json(indent=2)
179
+
180
+ # ── Signing ───────────────────────────────────────────────────
181
+
182
+ def sign(self, key: Ed25519PrivateKey | str | Path) -> ACM:
183
+ """Sign this manifest with an Ed25519 private key.
184
+
185
+ The signature is added to the manifest in-place.
186
+
187
+ Parameters
188
+ ----------
189
+ key:
190
+ An ``Ed25519PrivateKey`` instance, or a path to a PEM file.
191
+
192
+ Returns
193
+ -------
194
+ ACM
195
+ ``self``, with the signature attached (for chaining).
196
+ """
197
+ if isinstance(key, (str, Path)):
198
+ key = load_private_key(key)
199
+
200
+ acm_dict = json.loads(self._model.model_dump_json())
201
+ self._model.signature = sign_dict(acm_dict, key)
202
+ return self
203
+
204
+ # ── Verification ──────────────────────────────────────────────
205
+
206
+ def verify(
207
+ self,
208
+ *,
209
+ trusted_keys: list[str | Path | Ed25519PublicKey] | None = None,
210
+ ) -> None:
211
+ """Verify this manifest's temporal validity and optionally its signature.
212
+
213
+ Parameters
214
+ ----------
215
+ trusted_keys:
216
+ Optional list of trusted public keys (PEM paths or key instances)
217
+ to verify the signature against. If provided, signature verification
218
+ is performed against each key until one succeeds.
219
+
220
+ Raises
221
+ ------
222
+ ACMExpiredError
223
+ If the manifest has expired or is not yet valid.
224
+ ACMValidationError
225
+ If the manifest fails structural or delegation chain validation.
226
+ ACMSignatureError
227
+ If the signature is missing, invalid, or cannot be verified.
228
+ """
229
+ _validate(self._model)
230
+
231
+ if trusted_keys is not None:
232
+ if self._model.signature is None:
233
+ raise ACMSignatureError("Cannot verify signature: no signature in manifest.")
234
+ self._verify_signature(trusted_keys)
235
+
236
+ def _verify_signature(
237
+ self,
238
+ trusted_keys: list[str | Path | Ed25519PublicKey],
239
+ ) -> None:
240
+ """Verify the signature against one or more trusted keys."""
241
+ acm_dict = json.loads(self._model.model_dump_json())
242
+ signature = self._model.signature
243
+ if signature is None:
244
+ raise ACMSignatureError("No signature to verify")
245
+
246
+ errors: list[str] = []
247
+ for key_source in trusted_keys:
248
+ try:
249
+ if isinstance(key_source, (str, Path)):
250
+ pub_key = load_public_key(key_source)
251
+ else:
252
+ pub_key = key_source
253
+
254
+ if verify_dict(acm_dict, signature, pub_key):
255
+ return
256
+ except ACMError as e:
257
+ name = str(key_source) if isinstance(key_source, (str, Path)) else "provided key"
258
+ errors.append(f"{name}: {e}")
259
+
260
+ if errors:
261
+ details = "; ".join(errors)
262
+ raise ACMSignatureError(
263
+ f"Signature could not be verified against any trusted key: {details}"
264
+ )
265
+
266
+ # ── Delegation ────────────────────────────────────────────────
267
+
268
+ def delegate(
269
+ self,
270
+ child_spiffe_id: str,
271
+ capabilities: list[Capability],
272
+ *,
273
+ ttl: int = 3600,
274
+ key: Ed25519PrivateKey | str | Path | None = None,
275
+ ) -> ACM:
276
+ """Create a delegated ACM with narrowed scope.
277
+
278
+ Parameters
279
+ ----------
280
+ child_spiffe_id:
281
+ SPIFFE ID of the child agent.
282
+ capabilities:
283
+ Capabilities to delegate. Must be a subset of the parent's.
284
+ ttl:
285
+ Time-to-live in seconds for the delegated ACM.
286
+ key:
287
+ Optional private key to sign the delegated manifest.
288
+
289
+ Returns
290
+ -------
291
+ ACM
292
+ A new ACM instance representing the delegated manifest.
293
+ """
294
+ child_model = _delegate_model(
295
+ parent=self._model,
296
+ child_spiffe_id=child_spiffe_id,
297
+ child_capabilities=capabilities,
298
+ ttl=ttl,
299
+ )
300
+ child = ACM(child_model)
301
+
302
+ if key is not None:
303
+ child.sign(key)
304
+
305
+ return child
306
+
307
+ # ── Runtime Authorization Check ──────────────────────────────
308
+
309
+ def check(
310
+ self,
311
+ resource: str | None = None,
312
+ action: str | None = None,
313
+ *,
314
+ tool: str | None = None,
315
+ cost: float | None = None,
316
+ session_cost: float | None = None,
317
+ session_tokens: int | None = None,
318
+ model: str | None = None,
319
+ execution_seconds: float | None = None,
320
+ ) -> PolicyDecision:
321
+ """Evaluate whether an action is authorized right now.
322
+
323
+ This is the runtime authorization engine — it checks temporal
324
+ validity, capability presence, and all constraints (budget,
325
+ models, tools, execution time) in a single call.
326
+
327
+ Parameters
328
+ ----------
329
+ resource:
330
+ Resource type to check (e.g., ``"knowledge_base"``).
331
+ action:
332
+ Action to check (e.g., ``"read"``).
333
+ tool:
334
+ Tool name to check against ``disallowed_tools``.
335
+ cost:
336
+ Cost of this single action in USD.
337
+ session_cost:
338
+ Total cost so far in the current session in USD.
339
+ session_tokens:
340
+ Total tokens so far in the current session.
341
+ model:
342
+ Model name to check against ``allowed_models``.
343
+ execution_seconds:
344
+ Execution time so far in seconds.
345
+
346
+ Returns
347
+ -------
348
+ PolicyDecision
349
+ A structured decision with ``allowed``, ``reason``,
350
+ ``failed_constraint``, and ``remaining_budget``.
351
+
352
+ Example::
353
+
354
+ result = manifest.check(
355
+ resource="github",
356
+ action="search",
357
+ cost=0.02,
358
+ session_cost=0.20,
359
+ session_tokens=1000,
360
+ tool="github.search",
361
+ model="gpt-5",
362
+ )
363
+ if result.allowed:
364
+ print(f"Allowed — remaining budget: {result.remaining_budget}")
365
+ else:
366
+ print(f"Denied: {result.reason} ({result.failed_constraint})")
367
+ """
368
+ evaluator = RuntimeEvaluator()
369
+ return evaluator.check(
370
+ self._model,
371
+ resource=resource,
372
+ action=action,
373
+ tool=tool,
374
+ cost=cost,
375
+ session_cost=session_cost,
376
+ session_tokens=session_tokens,
377
+ model=model,
378
+ execution_seconds=execution_seconds,
379
+ )
380
+
381
+ # ── Capability Lookup ─────────────────────────────────────────
382
+
383
+ def has_capability(self, resource: str, action: str) -> bool:
384
+ """Check if a specific capability is present in this manifest.
385
+
386
+ Parameters
387
+ ----------
388
+ resource:
389
+ The resource type to check.
390
+ action:
391
+ The action to check.
392
+
393
+ Returns
394
+ -------
395
+ bool
396
+ True if the capability exists in this manifest.
397
+ """
398
+ return any(c.resource == resource and c.action == action for c in self._model.capabilities)
399
+
400
+ def get_capability(self, resource: str, action: str) -> Capability | None:
401
+ """Get a specific capability by resource and action.
402
+
403
+ Returns the ``Capability`` if found, or ``None``.
404
+ """
405
+ for c in self._model.capabilities:
406
+ if c.resource == resource and c.action == action:
407
+ return c
408
+ return None
409
+
410
+ # ── Temporal Checks ───────────────────────────────────────────
411
+
412
+ def is_valid(self) -> bool:
413
+ """Return True if this manifest is within its temporal validity window."""
414
+ return self._model.is_valid_now()
415
+
416
+ def is_expired(self) -> bool:
417
+ """Return True if this manifest has expired."""
418
+ return self._model.is_expired()
419
+
420
+ # ── Properties ────────────────────────────────────────────────
421
+
422
+ @property
423
+ def spiffe_id(self) -> str:
424
+ """The SPIFFE ID of the agent this manifest authorizes."""
425
+ return self._model.agent.spiffe_id
426
+
427
+ @property
428
+ def capabilities_list(self) -> list[Capability]:
429
+ """The list of capabilities in this manifest."""
430
+ return list(self._model.capabilities)
431
+
432
+ @property
433
+ def constraints_obj(self) -> Constraints | None:
434
+ """The global constraints on this manifest, or None."""
435
+ return self._model.constraints
436
+
437
+ @property
438
+ def sponsor(self) -> str:
439
+ """The human sponsor of this manifest."""
440
+ return self._model.human_sponsor
441
+
442
+ @property
443
+ def issuer(self) -> str:
444
+ """The SPIFFE ID of the signing entity."""
445
+ return self._model.issuer
446
+
447
+ @property
448
+ def expires_at(self) -> datetime:
449
+ """The expiration timestamp."""
450
+ return self._model.expires_at
451
+
452
+ @property
453
+ def not_before(self) -> datetime | None:
454
+ """The not-before timestamp, or None."""
455
+ return self._model.not_before
456
+
457
+ @property
458
+ def delegation_chain(self) -> list[str] | None:
459
+ """The delegation chain, or None if this is a root manifest."""
460
+ return self._model.delegation_chain
461
+
462
+ @property
463
+ def signature(self) -> str | None:
464
+ """The base64-encoded signature, or None if unsigned."""
465
+ return self._model.signature
466
+
467
+ @property
468
+ def metadata(self) -> dict[str, Any] | None:
469
+ """Implementation-specific metadata, or None."""
470
+ return self._model.metadata
471
+
472
+ @property
473
+ def acm_version(self) -> str:
474
+ """The ACM protocol version."""
475
+ return self._model.acm_version
476
+
477
+ @property
478
+ def agent_model(self) -> str | None:
479
+ """The LLM model name, or None."""
480
+ return self._model.agent.model
481
+
482
+ @property
483
+ def agent_provider(self) -> str | None:
484
+ """The model provider, or None."""
485
+ return self._model.agent.provider
486
+
487
+ def __repr__(self) -> str:
488
+ status = "valid" if self.is_valid() else "expired"
489
+ caps = len(self._model.capabilities)
490
+ sig = "signed" if self._model.signature else "unsigned"
491
+ return f"ACM(spiffe_id={self.spiffe_id!r}, capabilities={caps}, status={status}, {sig})"
capforge/_crypto.py ADDED
@@ -0,0 +1,149 @@
1
+ """Cryptographic signing and verification for ACM documents.
2
+
3
+ Uses Ed25519 signatures with canonical JSON serialization.
4
+ The canonical form is: JSON with sorted keys, no whitespace, no signature field.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
14
+ from cryptography.hazmat.primitives.serialization import (
15
+ Encoding,
16
+ NoEncryption,
17
+ PrivateFormat,
18
+ PublicFormat,
19
+ load_pem_private_key,
20
+ load_pem_public_key,
21
+ )
22
+
23
+ from capforge._exceptions import ACMSignatureError
24
+
25
+
26
+ def generate_private_key() -> Ed25519PrivateKey:
27
+ """Generate a new Ed25519 private key."""
28
+ return Ed25519PrivateKey.generate()
29
+
30
+
31
+ def private_key_to_bytes(key: Ed25519PrivateKey) -> bytes:
32
+ """Serialize a private key to PKCS#8 PEM bytes."""
33
+ return key.private_bytes(
34
+ encoding=Encoding.PEM,
35
+ format=PrivateFormat.PKCS8,
36
+ encryption_algorithm=NoEncryption(),
37
+ )
38
+
39
+
40
+ def public_key_to_bytes(key: Ed25519PrivateKey | Ed25519PublicKey) -> bytes:
41
+ """Serialize a public key to SubjectPublicKeyInfo PEM bytes."""
42
+ pub = key.public_key() if isinstance(key, Ed25519PrivateKey) else key
43
+ return pub.public_bytes(
44
+ encoding=Encoding.PEM,
45
+ format=PublicFormat.SubjectPublicKeyInfo,
46
+ )
47
+
48
+
49
+ def load_private_key(path: str | Path) -> Ed25519PrivateKey:
50
+ """Load an Ed25519 private key from a PEM file."""
51
+ data = Path(path).read_bytes()
52
+ key = load_pem_private_key(data, password=None)
53
+ if not isinstance(key, Ed25519PrivateKey):
54
+ msg = f"Expected Ed25519 private key, got {type(key).__name__}"
55
+ raise TypeError(msg)
56
+ return key
57
+
58
+
59
+ def load_public_key(path: str | Path) -> Ed25519PublicKey:
60
+ """Load an Ed25519 public key from a PEM file."""
61
+ data = Path(path).read_bytes()
62
+ key = load_pem_public_key(data)
63
+ if not isinstance(key, Ed25519PublicKey):
64
+ msg = f"Expected Ed25519 public key, got {type(key).__name__}"
65
+ raise TypeError(msg)
66
+ return key
67
+
68
+
69
+ def canonical_json(acm_dict: dict[str, object]) -> bytes:
70
+ """Serialize an ACM document to canonical JSON bytes.
71
+
72
+ The canonical form:
73
+ - Removes the ``signature`` field (if present).
74
+ - Sorts all keys lexicographically.
75
+ - Compact encoding (no whitespace).
76
+ - No trailing newline.
77
+ """
78
+ payload = {k: v for k, v in acm_dict.items() if k != "signature"}
79
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
80
+
81
+
82
+ def sign(canonical_bytes: bytes, private_key: Ed25519PrivateKey) -> str:
83
+ """Sign canonical ACM bytes and return a base64-encoded signature."""
84
+ sig = private_key.sign(canonical_bytes)
85
+ return base64.b64encode(sig).decode("ascii")
86
+
87
+
88
+ def verify(canonical_bytes: bytes, signature: str, public_key: Ed25519PublicKey) -> bool:
89
+ """Verify an ACM signature against canonical bytes.
90
+
91
+ Returns True if valid.
92
+ Raises ACMSignatureError if the signature is invalid.
93
+ """
94
+ try:
95
+ sig_bytes = base64.b64decode(signature)
96
+ public_key.verify(sig_bytes, canonical_bytes)
97
+ return True
98
+ except Exception as exc:
99
+ raise ACMSignatureError(f"Signature verification failed: {exc}") from exc
100
+
101
+
102
+ def sign_dict(acm_dict: dict[str, object], private_key: Ed25519PrivateKey) -> str:
103
+ """Canonicalize an ACM dict and sign it, returning the base64 signature."""
104
+ canon = canonical_json(acm_dict)
105
+ return sign(canon, private_key)
106
+
107
+
108
+ def verify_dict(
109
+ acm_dict: dict[str, object],
110
+ signature: str,
111
+ public_key: Ed25519PublicKey,
112
+ ) -> bool:
113
+ """Canonicalize an ACM dict and verify its signature."""
114
+ canon = canonical_json(acm_dict)
115
+ return verify(canon, signature, public_key)
116
+
117
+
118
+ def write_key_pair(
119
+ private_key: Ed25519PrivateKey,
120
+ public_key: Ed25519PublicKey | None = None,
121
+ path_prefix: str = "capforge-key",
122
+ ) -> tuple[str, str]:
123
+ """Write a key pair to PEM files.
124
+
125
+ Parameters
126
+ ----------
127
+ private_key:
128
+ The private key to write.
129
+ public_key:
130
+ The public key to write. If None, it is derived from the private key.
131
+ path_prefix:
132
+ Path prefix for the output files. ``{prefix}.pem`` (private)
133
+ and ``{prefix}.pub`` (public) will be created.
134
+
135
+ Returns
136
+ -------
137
+ tuple[str, str]
138
+ (private_key_path, public_key_path)
139
+ """
140
+ priv_path = f"{path_prefix}.pem"
141
+ Path(priv_path).write_bytes(private_key_to_bytes(private_key))
142
+
143
+ if public_key is None:
144
+ public_key = private_key.public_key()
145
+
146
+ pub_path = f"{path_prefix}.pub"
147
+ Path(pub_path).write_bytes(public_key_to_bytes(public_key))
148
+
149
+ return priv_path, pub_path