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 +50 -0
- capforge/_acm.py +491 -0
- capforge/_crypto.py +149 -0
- capforge/_delegation.py +83 -0
- capforge/_exceptions.py +21 -0
- capforge/_model.py +176 -0
- capforge/_policy.py +341 -0
- capforge/_validator.py +71 -0
- capforge/_version.py +3 -0
- capforge/cli/__init__.py +1 -0
- capforge/cli/main.py +347 -0
- capforge/py.typed +0 -0
- capforge-0.4.0.dist-info/METADATA +568 -0
- capforge-0.4.0.dist-info/RECORD +21 -0
- capforge-0.4.0.dist-info/WHEEL +5 -0
- capforge-0.4.0.dist-info/entry_points.txt +2 -0
- capforge-0.4.0.dist-info/licenses/LICENSE +201 -0
- capforge-0.4.0.dist-info/top_level.txt +2 -0
- capforge_langgraph/__init__.py +36 -0
- capforge_langgraph/_tool_wrapper.py +219 -0
- capforge_langgraph/pyproject.toml +35 -0
capforge/_delegation.py
ADDED
|
@@ -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
|
capforge/_exceptions.py
ADDED
|
@@ -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
capforge/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface for CapForge (ACM protocol)."""
|