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.
@@ -0,0 +1,83 @@
1
+ """Delegation logic for the ACM protocol.
2
+
3
+ Creates a delegated ACM (child) from a parent ACM, enforcing that the
4
+ child's capabilities are a strict subset of the parent's capabilities.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime, timedelta
10
+
11
+ from capforge._exceptions import ACMInvalidError
12
+ from capforge._model import (
13
+ Capability,
14
+ Constraints,
15
+ _ACMModel,
16
+ _AgentInfo,
17
+ )
18
+
19
+
20
+ def delegate(
21
+ parent: _ACMModel,
22
+ child_spiffe_id: str,
23
+ child_capabilities: list[Capability],
24
+ ttl: int = 3600,
25
+ constraints: Constraints | None = None,
26
+ ) -> _ACMModel:
27
+ """Create a delegated ACM model with narrowed scope.
28
+
29
+ Parameters
30
+ ----------
31
+ parent:
32
+ The parent ACM model to delegate from.
33
+ child_spiffe_id:
34
+ SPIFFE ID for the child agent.
35
+ child_capabilities:
36
+ Capabilities to delegate. Must be a subset of the parent's.
37
+ ttl:
38
+ Time-to-live in seconds for the delegated ACM.
39
+ constraints:
40
+ Optional constraints for the child. If None, inherits from parent.
41
+ If provided, must be at least as restrictive as the parent's.
42
+
43
+ Returns
44
+ -------
45
+ _ACMModel
46
+ A new ACM model representing the delegated document.
47
+
48
+ Raises
49
+ ------
50
+ ACMInvalidError
51
+ If any child capability is not in the parent's capabilities.
52
+ """
53
+ parent_resource_actions: set[tuple[str, str]] = {
54
+ (c.resource, c.action) for c in parent.capabilities
55
+ }
56
+
57
+ for c in child_capabilities:
58
+ if (c.resource, c.action) not in parent_resource_actions:
59
+ msg = (
60
+ f"Capability '{c.resource}:{c.action}' not found in parent ACM. "
61
+ "Child capabilities must be a subset of parent capabilities."
62
+ )
63
+ raise ACMInvalidError(msg)
64
+
65
+ delegation_chain: list[str] = (
66
+ [*parent.delegation_chain, parent.issuer] if parent.delegation_chain else [parent.issuer]
67
+ )
68
+
69
+ child_constraints = constraints if constraints is not None else parent.constraints
70
+
71
+ now = datetime.now(UTC)
72
+ child = _ACMModel(
73
+ agent=_AgentInfo(spiffe_id=child_spiffe_id),
74
+ human_sponsor=parent.human_sponsor,
75
+ capabilities=child_capabilities,
76
+ constraints=child_constraints,
77
+ delegation_chain=delegation_chain,
78
+ issuer=parent.issuer,
79
+ expires_at=now + timedelta(seconds=ttl),
80
+ not_before=now,
81
+ )
82
+
83
+ return child
@@ -0,0 +1,21 @@
1
+ """Custom exception hierarchy for the ACM protocol."""
2
+
3
+
4
+ class ACMError(Exception):
5
+ """Base exception for all ACM-related errors."""
6
+
7
+
8
+ class ACMValidationError(ACMError):
9
+ """Raised when an ACM document fails validation."""
10
+
11
+
12
+ class ACMSignatureError(ACMError):
13
+ """Raised when an ACM document's signature cannot be verified."""
14
+
15
+
16
+ class ACMExpiredError(ACMError):
17
+ """Raised when an ACM document has expired or is not yet valid."""
18
+
19
+
20
+ class ACMInvalidError(ACMError):
21
+ """Raised when an ACM document is structurally invalid."""
capforge/_model.py ADDED
@@ -0,0 +1,176 @@
1
+ """Pydantic data models for the ACM protocol.
2
+
3
+ This module contains the data models that represent the ACM protocol's
4
+ core data structures. ``Capability`` and ``Constraints`` are public
5
+ classes re-exported from ``capforge``. ``_ACMModel`` is the internal
6
+ Pydantic model used by the ``ACM`` wrapper class.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import UTC, datetime
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, Field, field_validator
15
+
16
+
17
+ class Capability(BaseModel):
18
+ """A single capability granted to an agent.
19
+
20
+ ``resource`` and ``action`` together define what the agent is allowed to do.
21
+ Optional ``constraints`` further scope the capability.
22
+
23
+ Example::
24
+
25
+ Capability(resource="knowledge_base", action="read")
26
+ Capability(resource="tickets", action="write",
27
+ constraints={"status": "resolved_only"})
28
+ """
29
+
30
+ resource: str = Field(
31
+ description="Resource type to access (e.g., 'knowledge_base', 'tickets')."
32
+ )
33
+ action: str = Field(
34
+ description="Action to perform on the resource (e.g., 'read', 'write', 'execute')."
35
+ )
36
+ constraints: dict[str, str] | None = Field(
37
+ default=None,
38
+ description="Optional key-value scoping constraints (e.g., {'status': 'resolved_only'}).",
39
+ )
40
+
41
+
42
+ class Constraints(BaseModel):
43
+ """Global constraints that apply to all capabilities in a manifest.
44
+
45
+ All fields are optional. When a field is ``None``, no constraint is
46
+ applied for that dimension.
47
+ """
48
+
49
+ max_cost_per_session: float | None = Field(
50
+ default=None,
51
+ ge=0,
52
+ description="Maximum USD cost allowed per session.",
53
+ )
54
+ max_tokens_per_session: int | None = Field(
55
+ default=None,
56
+ ge=0,
57
+ description="Maximum tokens allowed per session.",
58
+ )
59
+ allowed_models: list[str] | None = Field(
60
+ default=None,
61
+ description="If set, only these model names are permitted.",
62
+ )
63
+ disallowed_tools: list[str] | None = Field(
64
+ default=None,
65
+ description="Tool names that are always forbidden.",
66
+ )
67
+ max_execution_seconds: int | None = Field(
68
+ default=None,
69
+ ge=0,
70
+ description="Maximum wall-clock execution time in seconds.",
71
+ )
72
+
73
+
74
+ class _AgentInfo(BaseModel):
75
+ """Internal model: identifies the AI agent this manifest governs."""
76
+
77
+ spiffe_id: str = Field(
78
+ description="SPIFFE-compatible workload identifier.",
79
+ pattern=r"^spiffe://",
80
+ )
81
+ model: str | None = Field(
82
+ default=None,
83
+ description="LLM model name (e.g., 'gpt-5', 'claude-4').",
84
+ )
85
+ provider: str | None = Field(
86
+ default=None,
87
+ description="Model provider (e.g., 'openai', 'anthropic').",
88
+ )
89
+
90
+
91
+ class _ACMModel(BaseModel):
92
+ """Internal Pydantic model representing an ACM document.
93
+
94
+ This model handles structural validation (SPIFFE patterns, version const,
95
+ minimum capabilities). Business-logic validation (temporal bounds,
96
+ delegation chain) is handled by ``_validator``.
97
+ """
98
+
99
+ acm_version: str = Field(
100
+ default="1.0",
101
+ description="Protocol version. Must be '1.0' for this revision.",
102
+ )
103
+ agent: _AgentInfo = Field(description="The AI agent this manifest applies to.")
104
+ human_sponsor: str = Field(description="Email or SPIFFE ID of the responsible human or team.")
105
+ capabilities: list[Capability] = Field(
106
+ description="Capabilities granted to the agent (at least one required).",
107
+ min_length=1,
108
+ )
109
+ constraints: Constraints | None = Field(
110
+ default=None,
111
+ description="Optional global constraints.",
112
+ )
113
+ delegation_chain: list[str] | None = Field(
114
+ default=None,
115
+ description="Ordered SPIFFE IDs representing the delegation path.",
116
+ )
117
+ issuer: str = Field(
118
+ description="SPIFFE ID of the entity that signed this manifest.",
119
+ pattern=r"^spiffe://",
120
+ )
121
+ expires_at: datetime = Field(description="ISO 8601 UTC expiration timestamp.")
122
+ not_before: datetime | None = Field(
123
+ default=None,
124
+ description="ISO 8601 UTC timestamp before which this is invalid.",
125
+ )
126
+ signature: str | None = Field(
127
+ default=None,
128
+ description="Base64-encoded Ed25519 signature.",
129
+ )
130
+ metadata: dict[str, Any] | None = Field(
131
+ default=None,
132
+ description="Extension point for implementation-specific metadata.",
133
+ )
134
+
135
+ @field_validator("acm_version")
136
+ @classmethod
137
+ def _validate_version(cls, v: str) -> str:
138
+ if v != "1.0":
139
+ msg = f"Unsupported ACM version: {v!r}. Only '1.0' is supported."
140
+ raise ValueError(msg)
141
+ return v
142
+
143
+ @field_validator("capabilities")
144
+ @classmethod
145
+ def _validate_capabilities_not_empty(cls, v: list[Capability]) -> list[Capability]:
146
+ if not v:
147
+ msg = "At least one capability is required."
148
+ raise ValueError(msg)
149
+ return v
150
+
151
+ def is_expired(self, reference_time: datetime | None = None) -> bool:
152
+ """Return True if the current time is past ``expires_at``."""
153
+ now = reference_time or datetime.now(UTC)
154
+ expires = (
155
+ self.expires_at.replace(tzinfo=UTC)
156
+ if self.expires_at.tzinfo is None
157
+ else self.expires_at
158
+ )
159
+ ref = now.replace(tzinfo=UTC) if now.tzinfo is None else now
160
+ return ref > expires
161
+
162
+ def is_valid_now(self, reference_time: datetime | None = None) -> bool:
163
+ """Return True if the document is within its validity window."""
164
+ now = reference_time or datetime.now(UTC)
165
+ if self.is_expired(now):
166
+ return False
167
+ if self.not_before is not None:
168
+ ref = now.replace(tzinfo=UTC) if now.tzinfo is None else now
169
+ nb = (
170
+ self.not_before.replace(tzinfo=UTC)
171
+ if self.not_before.tzinfo is None
172
+ else self.not_before
173
+ )
174
+ if ref < nb:
175
+ return False
176
+ return True
capforge/_policy.py ADDED
@@ -0,0 +1,341 @@
1
+ """Runtime authorization engine for CapForge.
2
+
3
+ Transforms CapForge from a static manifest library into a runtime
4
+ authorization engine. The engine answers the question:
5
+
6
+ "May this action execute right now?"
7
+
8
+ Instead of:
9
+ "Does this capability exist?"
10
+
11
+ The public interface is ``ACM.check()``. The internal evaluators
12
+ (``RuntimeEvaluator``, ``ConstraintEvaluator``, ``BudgetEvaluator``)
13
+ are implementation details.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from datetime import UTC, datetime
20
+
21
+ from capforge._exceptions import ACMExpiredError
22
+ from capforge._model import Constraints, _ACMModel
23
+
24
+ # ── Public Types ──────────────────────────────────────────────────
25
+
26
+
27
+ @dataclass
28
+ class PolicyDecision:
29
+ """The result of a runtime authorization check.
30
+
31
+ Attributes
32
+ ----------
33
+ allowed:
34
+ Whether the action is authorized.
35
+ reason:
36
+ Human-readable explanation of the decision.
37
+ failed_constraint:
38
+ Which specific constraint was violated, if any.
39
+ Example: ``"max_cost_per_session"``, ``"capability"``, ``"expired"``.
40
+ remaining_budget:
41
+ If a budget constraint was evaluated, shows remaining capacity.
42
+ Keys are constraint names (e.g., ``"cost"``, ``"tokens"``).
43
+ """
44
+
45
+ allowed: bool
46
+ reason: str | None = None
47
+ failed_constraint: str | None = None
48
+ remaining_budget: dict[str, float] = field(default_factory=dict)
49
+
50
+
51
+ # ── Internal Evaluators ──────────────────────────────────────────
52
+
53
+
54
+ class BudgetEvaluator:
55
+ """Evaluates cost and token budget constraints.
56
+
57
+ Does NOT raise exceptions — returns a ``(allowed, remaining, reason)`` tuple.
58
+ """
59
+
60
+ @staticmethod
61
+ def check_cost(
62
+ constraints: Constraints | None,
63
+ cost: float | None,
64
+ session_cost: float | None,
65
+ ) -> tuple[bool, dict[str, float], str | None]:
66
+ """Check that the action's cost + session cost stays within budget.
67
+
68
+ Returns (allowed, remaining_budget, failed_constraint_name).
69
+ """
70
+ remaining: dict[str, float] = {}
71
+
72
+ if constraints is None or constraints.max_cost_per_session is None:
73
+ return True, remaining, None
74
+
75
+ budget = constraints.max_cost_per_session
76
+
77
+ # Session cost check
78
+ if session_cost is not None and session_cost > budget:
79
+ remaining["cost"] = budget
80
+ return False, remaining, "max_cost_per_session"
81
+
82
+ # Single action cost check
83
+ if cost is not None and cost > budget:
84
+ remaining["cost"] = budget
85
+ return False, remaining, "max_cost_per_session"
86
+
87
+ # Both checks pass — compute remaining budget
88
+ used = (session_cost or 0.0) + (cost or 0.0)
89
+ remaining["cost"] = max(0.0, budget - used)
90
+ return True, remaining, None
91
+
92
+ @staticmethod
93
+ def check_tokens(
94
+ constraints: Constraints | None,
95
+ session_tokens: int | None,
96
+ ) -> tuple[bool, dict[str, float], str | None]:
97
+ """Check that the session token count stays within budget."""
98
+ remaining: dict[str, float] = {}
99
+
100
+ if constraints is None or constraints.max_tokens_per_session is None:
101
+ return True, remaining, None
102
+
103
+ budget = float(constraints.max_tokens_per_session)
104
+
105
+ if session_tokens is not None and session_tokens > constraints.max_tokens_per_session:
106
+ remaining["tokens"] = budget
107
+ return False, remaining, "max_tokens_per_session"
108
+
109
+ remaining["tokens"] = max(0.0, budget - float(session_tokens or 0))
110
+ return True, remaining, None
111
+
112
+
113
+ class ConstraintEvaluator:
114
+ """Evaluates model, tool, and execution time constraints.
115
+
116
+ Does NOT raise exceptions — returns a ``(allowed, reason)`` tuple.
117
+ """
118
+
119
+ @staticmethod
120
+ def check_model(
121
+ constraints: Constraints | None,
122
+ model: str | None,
123
+ ) -> tuple[bool, str | None]:
124
+ """Check that *model* is in the allowed_models list (if set)."""
125
+ if constraints is None or constraints.allowed_models is None:
126
+ return True, None
127
+ if model is None:
128
+ return True, None
129
+ if model in constraints.allowed_models:
130
+ return True, None
131
+ return False, "allowed_models"
132
+
133
+ @staticmethod
134
+ def check_tool(
135
+ constraints: Constraints | None,
136
+ tool_name: str | None,
137
+ ) -> tuple[bool, str | None]:
138
+ """Check that *tool_name* is not in the disallowed_tools list."""
139
+ if constraints is None or constraints.disallowed_tools is None:
140
+ return True, None
141
+ if tool_name is None:
142
+ return True, None
143
+ if tool_name not in constraints.disallowed_tools:
144
+ return True, None
145
+ return False, "disallowed_tools"
146
+
147
+ @staticmethod
148
+ def check_execution_time(
149
+ constraints: Constraints | None,
150
+ execution_seconds: float | None,
151
+ ) -> tuple[bool, str | None]:
152
+ """Check that execution time is within the allowed limit."""
153
+ if constraints is None or constraints.max_execution_seconds is None:
154
+ return True, None
155
+ if execution_seconds is None:
156
+ return True, None
157
+ if execution_seconds <= constraints.max_execution_seconds:
158
+ return True, None
159
+ return False, "max_execution_seconds"
160
+
161
+
162
+ class RuntimeEvaluator:
163
+ """Main runtime evaluator — orchestrates all checks.
164
+
165
+ This is the internal implementation behind ``ACM.check()``.
166
+ """
167
+
168
+ def __init__(self) -> None:
169
+ self._budget = BudgetEvaluator()
170
+ self._constraint = ConstraintEvaluator()
171
+
172
+ def check(
173
+ self,
174
+ acm: _ACMModel,
175
+ *,
176
+ resource: str | None = None,
177
+ action: str | None = None,
178
+ tool: str | None = None,
179
+ cost: float | None = None,
180
+ session_cost: float | None = None,
181
+ session_tokens: int | None = None,
182
+ model: str | None = None,
183
+ execution_seconds: float | None = None,
184
+ ) -> PolicyDecision:
185
+ """Evaluate an action against an ACM document at runtime.
186
+
187
+ Parameters
188
+ ----------
189
+ acm:
190
+ The ACM document to evaluate.
191
+ resource:
192
+ Resource type to check in capabilities.
193
+ action:
194
+ Action to check in capabilities.
195
+ tool:
196
+ Tool name to check against ``disallowed_tools``.
197
+ cost:
198
+ Cost of this single action in USD.
199
+ session_cost:
200
+ Total cost so far in the current session in USD.
201
+ session_tokens:
202
+ Total tokens so far in the current session.
203
+ model:
204
+ Model name to check against ``allowed_models``.
205
+ execution_seconds:
206
+ Execution time so far in seconds.
207
+
208
+ Returns
209
+ -------
210
+ PolicyDecision
211
+ Structured result with decision, reason, and remaining budget.
212
+ """
213
+ remaining_budget: dict[str, float] = {}
214
+
215
+ # ── 1. Temporal check ─────────────────────────────────
216
+ try:
217
+ self._check_temporal(acm)
218
+ except ACMExpiredError as e:
219
+ return PolicyDecision(
220
+ allowed=False,
221
+ reason=str(e),
222
+ failed_constraint="expired",
223
+ )
224
+
225
+ # ── 2. Capability check ───────────────────────────────
226
+ if resource is not None or action is not None:
227
+ cap_decision = self._check_capability(acm, resource, action)
228
+ if not cap_decision.allowed:
229
+ return cap_decision
230
+
231
+ # ── 3. Model constraint ───────────────────────────────
232
+ model_ok, model_fail = self._constraint.check_model(acm.constraints, model)
233
+ if not model_ok:
234
+ return PolicyDecision(
235
+ allowed=False,
236
+ reason=f"Model '{model}' is not in allowed_models",
237
+ failed_constraint=model_fail,
238
+ )
239
+
240
+ # ── 4. Tool constraint ────────────────────────────────
241
+ tool_ok, tool_fail = self._constraint.check_tool(acm.constraints, tool)
242
+ if not tool_ok:
243
+ return PolicyDecision(
244
+ allowed=False,
245
+ reason=f"Tool '{tool}' is disallowed",
246
+ failed_constraint=tool_fail,
247
+ )
248
+
249
+ # ── 5. Execution time constraint ──────────────────────
250
+ time_ok, time_fail = self._constraint.check_execution_time(
251
+ acm.constraints, execution_seconds
252
+ )
253
+ if not time_ok:
254
+ return PolicyDecision(
255
+ allowed=False,
256
+ reason=f"Execution time {execution_seconds}s exceeds max allowed",
257
+ failed_constraint=time_fail,
258
+ )
259
+
260
+ # ── 6. Budget checks ──────────────────────────────────
261
+ budget_ok, budget_remaining, cost_fail = self._budget.check_cost(
262
+ acm.constraints, cost, session_cost
263
+ )
264
+ remaining_budget.update(budget_remaining)
265
+ if not budget_ok:
266
+ return PolicyDecision(
267
+ allowed=False,
268
+ reason=_budget_fail_reason(cost_fail, cost, session_cost),
269
+ failed_constraint=cost_fail,
270
+ remaining_budget=remaining_budget,
271
+ )
272
+
273
+ tokens_ok, tokens_remaining, tokens_fail = self._budget.check_tokens(
274
+ acm.constraints, session_tokens
275
+ )
276
+ remaining_budget.update(tokens_remaining)
277
+ if not tokens_ok:
278
+ return PolicyDecision(
279
+ allowed=False,
280
+ reason=f"Session token budget exceeded ({session_tokens} tokens used)",
281
+ failed_constraint=tokens_fail,
282
+ remaining_budget=remaining_budget,
283
+ )
284
+
285
+ # ── All checks passed ─────────────────────────────────
286
+ return PolicyDecision(
287
+ allowed=True,
288
+ reason="Action authorized",
289
+ remaining_budget=remaining_budget,
290
+ )
291
+
292
+ def _check_temporal(self, acm: _ACMModel) -> None:
293
+ """Check temporal validity — raises ACMExpiredError if invalid."""
294
+ now = datetime.now(UTC)
295
+ expires = (
296
+ acm.expires_at.replace(tzinfo=UTC) if acm.expires_at.tzinfo is None else acm.expires_at
297
+ )
298
+ if now > expires:
299
+ raise ACMExpiredError(f"ACM expired at {acm.expires_at}")
300
+
301
+ if acm.not_before is not None:
302
+ nb = (
303
+ acm.not_before.replace(tzinfo=UTC)
304
+ if acm.not_before.tzinfo is None
305
+ else acm.not_before
306
+ )
307
+ if now < nb:
308
+ raise ACMExpiredError(f"ACM is not yet valid until {acm.not_before}")
309
+
310
+ def _check_capability(
311
+ self,
312
+ acm: _ACMModel,
313
+ resource: str | None,
314
+ action: str | None,
315
+ ) -> PolicyDecision:
316
+ """Check that the capability exists in the ACM."""
317
+ if resource is None or action is None:
318
+ return PolicyDecision(allowed=True)
319
+
320
+ for cap in acm.capabilities:
321
+ if cap.resource == resource and cap.action == action:
322
+ return PolicyDecision(allowed=True)
323
+
324
+ return PolicyDecision(
325
+ allowed=False,
326
+ reason=f"Capability '{resource}:{action}' is not authorized",
327
+ failed_constraint="capability",
328
+ )
329
+
330
+
331
+ def _budget_fail_reason(
332
+ failed: str | None,
333
+ cost: float | None,
334
+ session_cost: float | None,
335
+ ) -> str:
336
+ """Build a human-readable budget failure message."""
337
+ if failed == "max_cost_per_session":
338
+ if session_cost is not None and (cost is None or session_cost > (cost or 0)):
339
+ return f"Session cost {session_cost} exceeds budget"
340
+ return f"Action cost {cost} exceeds budget"
341
+ return "Budget constraint exceeded"
capforge/_validator.py ADDED
@@ -0,0 +1,71 @@
1
+ """Validation logic for ACM documents.
2
+
3
+ The ``_validate`` function validates an ACM document against rules that
4
+ cannot be expressed by the Pydantic model alone:
5
+
6
+ - Temporal bounds (not expired, not before ``not_before``).
7
+ - Delegation chain invariants (all entries are valid SPIFFE IDs).
8
+
9
+ Structural validation (SPIFFE ID patterns, version const, minimum capabilities)
10
+ is handled by the Pydantic model.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import UTC, datetime
16
+
17
+ from capforge._exceptions import ACMExpiredError, ACMValidationError
18
+ from capforge._model import _ACMModel
19
+
20
+
21
+ def validate(acm: _ACMModel, reference_time: datetime | None = None) -> None:
22
+ """Validate an ACM document, raising on any violation.
23
+
24
+ Checks performed (Pydantic-level checks excluded — those fire during
25
+ model construction):
26
+
27
+ 1. Temporal validity — ``expires_at`` and ``not_before``.
28
+ 2. Delegation chain — every entry is a valid SPIFFE ID.
29
+
30
+ Parameters
31
+ ----------
32
+ acm:
33
+ The ACM document to validate.
34
+ reference_time:
35
+ Reference time for temporal checks. Defaults to current UTC time.
36
+
37
+ Raises
38
+ ------
39
+ ACMValidationError
40
+ If the document fails any validation rule.
41
+ ACMExpiredError
42
+ If the document is expired or not yet valid.
43
+ """
44
+ errors: list[str] = []
45
+ has_temporal_error = False
46
+
47
+ if acm.delegation_chain:
48
+ for i, entry in enumerate(acm.delegation_chain):
49
+ if not entry.startswith("spiffe://"):
50
+ errors.append(f"Invalid SPIFFE ID in delegation_chain[{i}]: {entry!r}")
51
+
52
+ ref = reference_time or datetime.now(UTC)
53
+ ref_utc = ref.replace(tzinfo=UTC) if ref.tzinfo is None else ref
54
+
55
+ expires = (
56
+ acm.expires_at.replace(tzinfo=UTC) if acm.expires_at.tzinfo is None else acm.expires_at
57
+ )
58
+ if ref_utc > expires:
59
+ errors.append(f"ACM expired at {acm.expires_at} (reference: {ref})")
60
+ has_temporal_error = True
61
+
62
+ if acm.not_before is not None:
63
+ nb = acm.not_before.replace(tzinfo=UTC) if acm.not_before.tzinfo is None else acm.not_before
64
+ if ref_utc < nb:
65
+ errors.append(f"ACM is not yet valid — not_before={acm.not_before} (reference: {ref})")
66
+ has_temporal_error = True
67
+
68
+ if errors:
69
+ if has_temporal_error:
70
+ raise ACMExpiredError("; ".join(errors))
71
+ raise ACMValidationError("; ".join(errors))
capforge/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Version information for the capforge package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Command-line interface for CapForge (ACM protocol)."""