intentplane 0.5.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.
- intentplane/__init__.py +30 -0
- intentplane/cedar_pdp.py +68 -0
- intentplane/core.py +538 -0
- intentplane/packs/__init__.py +0 -0
- intentplane/packs/accounts_payable.cedar +53 -0
- intentplane/packs/accounts_payable.py +152 -0
- intentplane/packs/bank.cedar +18 -0
- intentplane/packs/bank.py +73 -0
- intentplane/packs/code.cedar +18 -0
- intentplane/packs/code.py +49 -0
- intentplane/packs/health.cedar +22 -0
- intentplane/packs/health.py +49 -0
- intentplane/proxy/__init__.py +13 -0
- intentplane/proxy/config.py +147 -0
- intentplane/proxy/gate.py +154 -0
- intentplane/proxy/server.py +113 -0
- intentplane-0.5.0.dist-info/METADATA +293 -0
- intentplane-0.5.0.dist-info/RECORD +22 -0
- intentplane-0.5.0.dist-info/WHEEL +5 -0
- intentplane-0.5.0.dist-info/entry_points.txt +2 -0
- intentplane-0.5.0.dist-info/licenses/LICENSE +21 -0
- intentplane-0.5.0.dist-info/top_level.txt +1 -0
intentplane/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""intentplane: domain-independent intent control plane for LLM agents."""
|
|
2
|
+
from .core import (
|
|
3
|
+
ActionContract,
|
|
4
|
+
Approvals,
|
|
5
|
+
Authority,
|
|
6
|
+
Catalog,
|
|
7
|
+
Decision,
|
|
8
|
+
DictRegistry,
|
|
9
|
+
Effect,
|
|
10
|
+
Free,
|
|
11
|
+
FromMandate,
|
|
12
|
+
FromRegistry,
|
|
13
|
+
Gateway,
|
|
14
|
+
Ledger,
|
|
15
|
+
Mandate,
|
|
16
|
+
Origin,
|
|
17
|
+
Outcome,
|
|
18
|
+
Pack,
|
|
19
|
+
Param,
|
|
20
|
+
Proposal,
|
|
21
|
+
ResolvedIntent,
|
|
22
|
+
RulePDP,
|
|
23
|
+
Status,
|
|
24
|
+
Tagged,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = ["ActionContract", "Approvals", "Authority", "Catalog", "Decision", "DictRegistry", "Effect", "Free",
|
|
28
|
+
"FromMandate", "FromRegistry", "Gateway", "Ledger", "Mandate", "Origin", "Outcome", "Pack", "Param",
|
|
29
|
+
"Proposal", "ResolvedIntent", "RulePDP", "Status", "Tagged"]
|
|
30
|
+
__version__ = "0.3.0"
|
intentplane/cedar_pdp.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Cedar as the policy decision point.
|
|
2
|
+
|
|
3
|
+
Cedar decides with default deny and forbid-overrides-permit. Escalation lives in the policy too: the
|
|
4
|
+
request is evaluated at `context.approval_level` 0 (none), 1 (step-up: the principal confirms) and
|
|
5
|
+
2 (four-eyes: a second human), and the decision is the SMALLEST level at which Cedar permits. If no
|
|
6
|
+
level permits, the answer is DENY with the forbids that applied. The request Cedar sees is built by
|
|
7
|
+
the gateway from the resolved intent, never by the agent:
|
|
8
|
+
|
|
9
|
+
principal Principal::"<principal>" { scopes }
|
|
10
|
+
action Action::"<contract name>"
|
|
11
|
+
resource Resource::"<contract resource>"
|
|
12
|
+
context { params: {...values}, origin: {...origins}, approval_level, <context providers...> }
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import cedarpy
|
|
20
|
+
|
|
21
|
+
from .core import ActionContract, Decision, Effect, ResolvedIntent
|
|
22
|
+
|
|
23
|
+
LEVELS = ((0, Effect.ALLOW), (1, Effect.STEP_UP), (2, Effect.FOUR_EYES))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cedar_value(v):
|
|
27
|
+
"""Cedar has no null and no float: drop None, refuse floats (use integer minor units)."""
|
|
28
|
+
if isinstance(v, float):
|
|
29
|
+
raise TypeError("Cedar has no float type: use integer units (e.g. cents)")
|
|
30
|
+
if isinstance(v, dict):
|
|
31
|
+
return {k: _cedar_value(x) for k, x in v.items() if x is not None}
|
|
32
|
+
return v
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CedarPDP:
|
|
36
|
+
def __init__(self, version: str, policies: str) -> None:
|
|
37
|
+
self.version = version
|
|
38
|
+
self.policies = policies
|
|
39
|
+
self._ids = re.findall(r'@id\("([^"]+)"\)', policies)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_file(cls, version: str, path: Path) -> CedarPDP:
|
|
43
|
+
return cls(version, Path(path).read_text(encoding="utf-8"))
|
|
44
|
+
|
|
45
|
+
def _name(self, pid: str) -> str:
|
|
46
|
+
m = re.fullmatch(r"policy(\d+)", pid)
|
|
47
|
+
return self._ids[int(m.group(1))] if m and int(m.group(1)) < len(self._ids) else pid
|
|
48
|
+
|
|
49
|
+
def evaluate(self, intent: ResolvedIntent, contract: ActionContract) -> Decision:
|
|
50
|
+
base = _cedar_value({"params": intent.values(),
|
|
51
|
+
"origin": {k: t.origin.value for k, t in intent.params.items()},
|
|
52
|
+
**dict(intent.context)})
|
|
53
|
+
entities = [{"uid": {"type": "Principal", "id": intent.mandate.principal},
|
|
54
|
+
"attrs": {"scopes": sorted(intent.mandate.scopes)}, "parents": []},
|
|
55
|
+
{"uid": {"type": "Resource", "id": contract.resource}, "attrs": {}, "parents": []}]
|
|
56
|
+
request = {"principal": f'Principal::"{intent.mandate.principal}"', "action": f'Action::"{contract.name}"',
|
|
57
|
+
"resource": f'Resource::"{contract.resource}"'}
|
|
58
|
+
names: tuple[str, ...] = ()
|
|
59
|
+
for level, effect in LEVELS:
|
|
60
|
+
res = cedarpy.is_authorized({**request, "context": {**base, "approval_level": level}},
|
|
61
|
+
self.policies, entities)
|
|
62
|
+
if res.diagnostics.errors: # evaluation error => fail closed
|
|
63
|
+
return Decision(Effect.DENY, "policy", ("policy evaluation error",), self.version)
|
|
64
|
+
names = tuple(sorted(self._name(p) for p in res.diagnostics.reasons))
|
|
65
|
+
if res.allowed:
|
|
66
|
+
return Decision(effect, "policy", names, self.version)
|
|
67
|
+
# Not permitted even with four-eyes: report the forbids that applied (determining policies at level 2).
|
|
68
|
+
return Decision(Effect.DENY, "policy", names or ("no policy permits this action",), self.version)
|
intentplane/core.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""intentplane.core: domain-independent intent control plane for LLM agents.
|
|
2
|
+
|
|
3
|
+
Trust boundary (the whole point of the design):
|
|
4
|
+
|
|
5
|
+
* The agent produces ONE thing: a `Proposal` = an action name and raw argument values.
|
|
6
|
+
It cannot express an origin, an identity, a scope or a context. Anything it sends is data.
|
|
7
|
+
* The gateway resolves everything else itself, from trusted sources only:
|
|
8
|
+
identity, scopes, mandate <- a signed token issued by the trusted side (`Authority`)
|
|
9
|
+
provenance of each value <- the action contract says where a critical value may come
|
|
10
|
+
from (`FromMandate`, `FromRegistry`); the gateway looks it up
|
|
11
|
+
context (sanctions, CI ...) <- context providers that query systems of record
|
|
12
|
+
* Then, in order: contract -> provenance -> delegation -> budget -> policy (default deny)
|
|
13
|
+
-> human approval bound to the operation hash -> execution by the gateway (the only
|
|
14
|
+
credential holder) -> hash-chained receipt. Any error at any stage => DENY.
|
|
15
|
+
|
|
16
|
+
Security property (tests/test_property.py): whatever the agent proposes, an action is executed
|
|
17
|
+
only if every critical value equals a value from a trusted source for that token, the principal
|
|
18
|
+
holds the scope, and the mandate's budget is respected.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import hmac
|
|
24
|
+
import json
|
|
25
|
+
import secrets
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Mapping
|
|
29
|
+
from dataclasses import dataclass, field, replace
|
|
30
|
+
from enum import Enum
|
|
31
|
+
from typing import Any, Protocol
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _canon(obj: Any) -> str:
|
|
35
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _h(obj: Any) -> str:
|
|
39
|
+
return hashlib.sha256(_canon(obj).encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _jsonable(v: Any) -> Any:
|
|
43
|
+
"""The agent's raw message, kept verbatim in the receipt (anything not JSON becomes its repr)."""
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(json.dumps(v, default=repr))
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return repr(v)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
# What the agent may produce
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Proposal:
|
|
55
|
+
"""The only output of the agent. Values are raw JSON-like data; a registry-backed value may be
|
|
56
|
+
given as {"ref": "<record id>"}. There is deliberately no field for origin, scope or context."""
|
|
57
|
+
action: str
|
|
58
|
+
args: Mapping[str, Any]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
# Provenance: produced by the gateway only
|
|
63
|
+
# --------------------------------------------------------------------------- #
|
|
64
|
+
class Origin(str, Enum):
|
|
65
|
+
SYSTEM_OF_RECORD = "system_of_record" # read by the gateway from a registry
|
|
66
|
+
USER_CHANNEL = "user_channel" # bound in the principal's signed mandate
|
|
67
|
+
UNTRUSTED = "untrusted" # anything only the agent vouches for
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Tagged:
|
|
72
|
+
value: Any
|
|
73
|
+
origin: Origin
|
|
74
|
+
ref: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class FromMandate:
|
|
79
|
+
"""The value must equal claim `field` of the signed mandate (or one of its items, if the claim is a list:
|
|
80
|
+
e.g. the payees the user named in the request)."""
|
|
81
|
+
field: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class FromRegistry:
|
|
86
|
+
"""The value is read from registry `registry` (by {"ref": id}, or found by value)."""
|
|
87
|
+
registry: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Free:
|
|
92
|
+
"""Any value, labelled UNTRUSTED. Not allowed for critical parameters."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
Source = FromMandate | FromRegistry | Free
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Registry(Protocol):
|
|
99
|
+
"""A system of record, always queried for the mandate's owner (tenant or principal): Alice's beneficiaries are
|
|
100
|
+
not Bob's, one company's vendors are not another's."""
|
|
101
|
+
def get(self, principal: str, ref: str) -> Any | None: ...
|
|
102
|
+
def find(self, principal: str, value: Any) -> str | None: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DictRegistry:
|
|
106
|
+
"""In-memory system of record: principal -> {ref -> value}."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, records: Mapping[str, Mapping[str, Any]], normalize: Callable[[Any], Any] | None = None):
|
|
109
|
+
self._records = {p: dict(r) for p, r in records.items()}
|
|
110
|
+
self._norm = normalize or (lambda v: v)
|
|
111
|
+
|
|
112
|
+
def get(self, principal: str, ref: str) -> Any | None:
|
|
113
|
+
return self._records.get(principal, {}).get(ref)
|
|
114
|
+
|
|
115
|
+
def add(self, principal: str, ref: str, value: Any) -> None:
|
|
116
|
+
"""Trusted side only (e.g. the user adds a beneficiary in the bank's own app)."""
|
|
117
|
+
self._records.setdefault(principal, {})[ref] = value
|
|
118
|
+
|
|
119
|
+
def find(self, principal: str, value: Any) -> str | None:
|
|
120
|
+
try:
|
|
121
|
+
n = self._norm(value)
|
|
122
|
+
except Exception: # noqa: BLE001 (an unnormalisable value is simply not found)
|
|
123
|
+
return None
|
|
124
|
+
return next((r for r, v in self._records.get(principal, {}).items() if self._norm(v) == n), None)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
# Action contracts
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Param:
|
|
132
|
+
name: str
|
|
133
|
+
type: type
|
|
134
|
+
critical: bool = False
|
|
135
|
+
sources: tuple[Source, ...] = (Free(),)
|
|
136
|
+
validate: Callable[[Any], bool] | None = None
|
|
137
|
+
normalize: Callable[[Any], Any] | None = None
|
|
138
|
+
each: bool = False # the value is a list, and EVERY item must come from the sources (e.g. email recipients)
|
|
139
|
+
|
|
140
|
+
def __post_init__(self) -> None:
|
|
141
|
+
if not self.sources:
|
|
142
|
+
raise ValueError(f"{self.name}: no source")
|
|
143
|
+
if self.critical and any(isinstance(s, Free) for s in self.sources):
|
|
144
|
+
raise ValueError(f"{self.name}: a critical parameter cannot accept Free values")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class ActionContract:
|
|
149
|
+
name: str
|
|
150
|
+
resource: str
|
|
151
|
+
params: tuple[Param, ...]
|
|
152
|
+
version: str = "1"
|
|
153
|
+
budget_param: str | None = None # numeric param charged against the mandate's budget
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Catalog:
|
|
157
|
+
"""The finite attack surface: every action the agent may name, and nothing else."""
|
|
158
|
+
|
|
159
|
+
def __init__(self) -> None:
|
|
160
|
+
self._contracts: dict[str, ActionContract] = {}
|
|
161
|
+
|
|
162
|
+
def register(self, c: ActionContract) -> None:
|
|
163
|
+
if c.name in self._contracts:
|
|
164
|
+
raise ValueError(f"duplicate contract {c.name}")
|
|
165
|
+
self._contracts[c.name] = c
|
|
166
|
+
|
|
167
|
+
def get(self, name: str) -> ActionContract | None:
|
|
168
|
+
return self._contracts.get(name)
|
|
169
|
+
|
|
170
|
+
def names(self) -> list[str]:
|
|
171
|
+
return sorted(self._contracts)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
# Identity: signed mandates issued by the trusted side
|
|
176
|
+
# --------------------------------------------------------------------------- #
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class Mandate:
|
|
179
|
+
principal: str
|
|
180
|
+
scopes: frozenset[str]
|
|
181
|
+
claims: Mapping[str, Any]
|
|
182
|
+
jti: str
|
|
183
|
+
exp: float
|
|
184
|
+
budget: float | None = None
|
|
185
|
+
max_calls: int | None = None
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def owner(self) -> str:
|
|
189
|
+
"""Whose systems of record this mandate reads: the tenant (e.g. the company whose vendors and invoices
|
|
190
|
+
its accountants share) when the signed mandate names one, otherwise the principal themself."""
|
|
191
|
+
return str(self.claims.get("tenant", self.principal))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Authority:
|
|
195
|
+
"""Issues and verifies mandate tokens. Lives on the trusted side (the bank app, the EHR, ...).
|
|
196
|
+
Production: asymmetric signatures / workload identity instead of a shared HMAC key."""
|
|
197
|
+
|
|
198
|
+
def __init__(self) -> None:
|
|
199
|
+
self._key = secrets.token_bytes(32)
|
|
200
|
+
self._revoked: set[str] = set()
|
|
201
|
+
|
|
202
|
+
def issue(self, principal: str, scopes: set[str] | frozenset[str], claims: Mapping[str, Any] | None = None, *,
|
|
203
|
+
budget: float | None = None, max_calls: int | None = None, ttl: float = 3600) -> str:
|
|
204
|
+
payload = _canon({"sub": principal, "scopes": sorted(scopes), "claims": dict(claims or {}),
|
|
205
|
+
"budget": budget, "max_calls": max_calls, "exp": time.time() + ttl,
|
|
206
|
+
"jti": secrets.token_hex(16)})
|
|
207
|
+
return payload + "." + hmac.new(self._key, payload.encode(), hashlib.sha256).hexdigest()
|
|
208
|
+
|
|
209
|
+
def revoke(self, principal: str) -> None:
|
|
210
|
+
self._revoked.add(principal)
|
|
211
|
+
|
|
212
|
+
def verify(self, token: str) -> Mandate | None:
|
|
213
|
+
try:
|
|
214
|
+
payload, sig = token.rsplit(".", 1)
|
|
215
|
+
good = hmac.new(self._key, payload.encode(), hashlib.sha256).hexdigest()
|
|
216
|
+
if not hmac.compare_digest(sig, good):
|
|
217
|
+
return None
|
|
218
|
+
c = json.loads(payload)
|
|
219
|
+
if c["exp"] < time.time() or c["sub"] in self._revoked:
|
|
220
|
+
return None
|
|
221
|
+
return Mandate(c["sub"], frozenset(c["scopes"]), c["claims"], c["jti"], c["exp"],
|
|
222
|
+
c["budget"], c["max_calls"])
|
|
223
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --------------------------------------------------------------------------- #
|
|
228
|
+
# Resolved intent, decisions
|
|
229
|
+
# --------------------------------------------------------------------------- #
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class ResolvedIntent:
|
|
232
|
+
"""Built by the gateway only: what the agent asked for, with trusted provenance and context."""
|
|
233
|
+
action: str
|
|
234
|
+
params: Mapping[str, Tagged]
|
|
235
|
+
mandate: Mandate
|
|
236
|
+
context: Mapping[str, Any]
|
|
237
|
+
|
|
238
|
+
def values(self) -> dict[str, Any]:
|
|
239
|
+
return {k: t.value for k, t in self.params.items()}
|
|
240
|
+
|
|
241
|
+
def canonical(self) -> dict[str, Any]:
|
|
242
|
+
"""What a human approver is shown, and what the approval signature covers."""
|
|
243
|
+
return {"action": self.action, "principal": self.mandate.principal, "mandate": self.mandate.jti,
|
|
244
|
+
"params": {k: t.value for k, t in sorted(self.params.items())},
|
|
245
|
+
"provenance": {k: [t.origin.value, t.ref] for k, t in sorted(self.params.items())}}
|
|
246
|
+
|
|
247
|
+
def op_hash(self) -> str:
|
|
248
|
+
return _h(self.canonical())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class Effect(str, Enum):
|
|
252
|
+
ALLOW = "allow"
|
|
253
|
+
STEP_UP = "step_up" # the principal confirms with strong authentication
|
|
254
|
+
FOUR_EYES = "four_eyes" # a second, different human approves
|
|
255
|
+
DENY = "deny"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_RANK = {Effect.ALLOW: 0, Effect.STEP_UP: 1, Effect.FOUR_EYES: 2, Effect.DENY: 3}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True)
|
|
262
|
+
class Decision:
|
|
263
|
+
effect: Effect
|
|
264
|
+
stage: str
|
|
265
|
+
reasons: tuple[str, ...]
|
|
266
|
+
policy_version: str = ""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class PDP(Protocol):
|
|
270
|
+
version: str
|
|
271
|
+
|
|
272
|
+
def evaluate(self, intent: ResolvedIntent, contract: ActionContract) -> Decision: ...
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
Rule = Callable[[ResolvedIntent, ActionContract], "Decision | None"]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class RulePDP:
|
|
279
|
+
"""Deterministic reference PDP, DEFAULT DENY. Rules return ALLOW (a permit), STEP_UP / FOUR_EYES
|
|
280
|
+
(obligations), DENY (a forbid) or None. Any DENY wins; without at least one permit the answer is
|
|
281
|
+
DENY; otherwise the strongest obligation applies."""
|
|
282
|
+
|
|
283
|
+
def __init__(self, version: str, rules: list[Rule]) -> None:
|
|
284
|
+
self.version = version
|
|
285
|
+
self._rules = rules
|
|
286
|
+
|
|
287
|
+
def evaluate(self, intent: ResolvedIntent, contract: ActionContract) -> Decision:
|
|
288
|
+
decisions = [d for d in (r(intent, contract) for r in self._rules) if d is not None]
|
|
289
|
+
denies = [d for d in decisions if d.effect is Effect.DENY]
|
|
290
|
+
if denies:
|
|
291
|
+
return Decision(Effect.DENY, "policy", tuple(r for d in denies for r in d.reasons), self.version)
|
|
292
|
+
if not any(d.effect is Effect.ALLOW for d in decisions):
|
|
293
|
+
return Decision(Effect.DENY, "policy", ("no policy permits this action",), self.version)
|
|
294
|
+
top = max(decisions, key=lambda d: _RANK[d.effect])
|
|
295
|
+
return Decision(top.effect, "policy", tuple(r for d in decisions for r in d.reasons), self.version)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --------------------------------------------------------------------------- #
|
|
299
|
+
# Human approval bound to the operation hash (single-use, expiring)
|
|
300
|
+
# --------------------------------------------------------------------------- #
|
|
301
|
+
class Approvals:
|
|
302
|
+
"""Approvers sign op_hash|nonce|exp with a key that lives on their own device (simulated here)."""
|
|
303
|
+
|
|
304
|
+
def __init__(self, humans: Mapping[str, bytes]) -> None:
|
|
305
|
+
self._humans = dict(humans)
|
|
306
|
+
self._used: set[str] = set()
|
|
307
|
+
|
|
308
|
+
def approve(self, human: str, op_hash: str, ttl: float = 300) -> dict:
|
|
309
|
+
nonce, exp = secrets.token_hex(16), time.time() + ttl
|
|
310
|
+
msg = f"{op_hash}|{nonce}|{exp}".encode()
|
|
311
|
+
return {"human": human, "op_hash": op_hash, "nonce": nonce, "exp": exp,
|
|
312
|
+
"sig": hmac.new(self._humans[human], msg, hashlib.sha256).hexdigest()}
|
|
313
|
+
|
|
314
|
+
def check(self, a: Mapping, op_hash: str) -> str | None:
|
|
315
|
+
"""The approving human if `a` is a valid, unused approval of exactly this operation."""
|
|
316
|
+
try:
|
|
317
|
+
key = self._humans.get(a["human"])
|
|
318
|
+
msg = f"{a['op_hash']}|{a['nonce']}|{a['exp']}".encode()
|
|
319
|
+
ok = (key is not None and hmac.compare_digest(a["sig"], hmac.new(key, msg, hashlib.sha256).hexdigest())
|
|
320
|
+
and a["exp"] >= time.time() and a["op_hash"] == op_hash and a["nonce"] not in self._used)
|
|
321
|
+
return a["human"] if ok else None
|
|
322
|
+
except (KeyError, TypeError, AttributeError):
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
def consume(self, a: Mapping) -> None:
|
|
326
|
+
self._used.add(a["nonce"])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --------------------------------------------------------------------------- #
|
|
330
|
+
# Receipts (append-only, hash-chained)
|
|
331
|
+
# --------------------------------------------------------------------------- #
|
|
332
|
+
class Ledger:
|
|
333
|
+
def __init__(self) -> None:
|
|
334
|
+
self.entries: list[dict] = []
|
|
335
|
+
|
|
336
|
+
def append(self, **data: Any) -> dict:
|
|
337
|
+
body = {"seq": len(self.entries), "ts": time.time(), "data": data,
|
|
338
|
+
"prev": self.entries[-1]["hash"] if self.entries else "0" * 64}
|
|
339
|
+
body["hash"] = _h(body)
|
|
340
|
+
self.entries.append(body)
|
|
341
|
+
return body
|
|
342
|
+
|
|
343
|
+
def verify(self) -> bool:
|
|
344
|
+
prev = "0" * 64
|
|
345
|
+
for e in self.entries:
|
|
346
|
+
body = {k: v for k, v in e.items() if k != "hash"}
|
|
347
|
+
if e["prev"] != prev or _h(body) != e["hash"]:
|
|
348
|
+
return False
|
|
349
|
+
prev = e["hash"]
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
def __len__(self) -> int:
|
|
353
|
+
return len(self.entries)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# --------------------------------------------------------------------------- #
|
|
357
|
+
# Domain pack
|
|
358
|
+
# --------------------------------------------------------------------------- #
|
|
359
|
+
ContextProvider = Callable[[str, Mapping[str, Any], Mandate], Any] # (action, resolved values, mandate)
|
|
360
|
+
Executor = Callable[[Mapping[str, Any], Mandate], Any] # (resolved values, the verified mandate)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass
|
|
364
|
+
class Pack:
|
|
365
|
+
"""Everything domain-specific: contracts, policy, trusted sources, and how to execute."""
|
|
366
|
+
name: str
|
|
367
|
+
contracts: list[ActionContract]
|
|
368
|
+
pdp: PDP
|
|
369
|
+
registries: dict[str, Registry] = field(default_factory=dict)
|
|
370
|
+
context: dict[str, ContextProvider] = field(default_factory=dict)
|
|
371
|
+
executors: dict[str, Executor] = field(default_factory=dict)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# --------------------------------------------------------------------------- #
|
|
375
|
+
# Gateway (policy enforcement point)
|
|
376
|
+
# --------------------------------------------------------------------------- #
|
|
377
|
+
class Status(str, Enum):
|
|
378
|
+
EXECUTED = "EXECUTED"
|
|
379
|
+
PENDING = "PENDING" # policy asks for a human: approve `op_hash` and resubmit
|
|
380
|
+
DENIED = "DENIED"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass(frozen=True)
|
|
384
|
+
class Outcome:
|
|
385
|
+
status: Status
|
|
386
|
+
decision: Decision
|
|
387
|
+
op_hash: str | None = None
|
|
388
|
+
result: Any = None
|
|
389
|
+
approvers: tuple[str, ...] = ()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class _Deny(Exception):
|
|
393
|
+
def __init__(self, stage: str, *reasons: str) -> None:
|
|
394
|
+
super().__init__(stage)
|
|
395
|
+
self.decision = Decision(Effect.DENY, stage, reasons)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class Gateway:
|
|
399
|
+
def __init__(self, packs: list[Pack], authority: Authority, approvals: Approvals | None = None,
|
|
400
|
+
ledger: Ledger | None = None) -> None:
|
|
401
|
+
self.catalog = Catalog()
|
|
402
|
+
self._pack_of: dict[str, Pack] = {}
|
|
403
|
+
for p in packs:
|
|
404
|
+
for c in p.contracts:
|
|
405
|
+
self.catalog.register(c)
|
|
406
|
+
self._pack_of[c.name] = p
|
|
407
|
+
self.authority = authority
|
|
408
|
+
self.approvals = approvals or Approvals({})
|
|
409
|
+
self.ledger = ledger if ledger is not None else Ledger() # an EMPTY ledger is falsy (__len__)
|
|
410
|
+
self._spent: dict[str, float] = {}
|
|
411
|
+
self._calls: dict[str, int] = {}
|
|
412
|
+
self._lock = threading.Lock()
|
|
413
|
+
|
|
414
|
+
def submit(self, token: str, proposal: Proposal, approvals: tuple[Mapping, ...] = ()) -> Outcome:
|
|
415
|
+
with self._lock:
|
|
416
|
+
intent = None
|
|
417
|
+
try:
|
|
418
|
+
outcome, intent = self._submit(token, proposal, approvals)
|
|
419
|
+
except _Deny as d:
|
|
420
|
+
outcome = Outcome(Status.DENIED, d.decision)
|
|
421
|
+
except Exception as e: # noqa: BLE001 fail closed: an error never becomes an ALLOW
|
|
422
|
+
outcome = Outcome(Status.DENIED, Decision(Effect.DENY, "error", (f"{type(e).__name__}: {e}",)))
|
|
423
|
+
self.ledger.append(action=getattr(proposal, "action", None),
|
|
424
|
+
raw=_jsonable(getattr(proposal, "args", proposal)), # what the agent actually sent
|
|
425
|
+
intent=intent.canonical() if intent else None,
|
|
426
|
+
status=outcome.status.value, stage=outcome.decision.stage,
|
|
427
|
+
reasons=list(outcome.decision.reasons), approvers=list(outcome.approvers),
|
|
428
|
+
policy_version=outcome.decision.policy_version)
|
|
429
|
+
return outcome
|
|
430
|
+
|
|
431
|
+
# -------------------------------------------------------------------- #
|
|
432
|
+
def _submit(self, token: str, proposal: Proposal, approvals: tuple[Mapping, ...]):
|
|
433
|
+
m = self.authority.verify(token)
|
|
434
|
+
if m is None:
|
|
435
|
+
raise _Deny("authn", "invalid, expired, forged or revoked token")
|
|
436
|
+
if not isinstance(proposal, Proposal) or not isinstance(proposal.args, Mapping):
|
|
437
|
+
raise _Deny("contract", "malformed proposal")
|
|
438
|
+
contract = self.catalog.get(proposal.action)
|
|
439
|
+
if contract is None:
|
|
440
|
+
raise _Deny("contract", f"unknown action {proposal.action!r}")
|
|
441
|
+
pack = self._pack_of[contract.name]
|
|
442
|
+
|
|
443
|
+
expected = {p.name for p in contract.params}
|
|
444
|
+
extra, missing = set(proposal.args) - expected, expected - set(proposal.args)
|
|
445
|
+
if extra or missing:
|
|
446
|
+
raise _Deny("contract", *([f"unexpected params {sorted(extra)}"] if extra else []),
|
|
447
|
+
*([f"missing params {sorted(missing)}"] if missing else []))
|
|
448
|
+
|
|
449
|
+
params = {p.name: self._resolve(p, proposal.args[p.name], m, pack) for p in contract.params}
|
|
450
|
+
|
|
451
|
+
if contract.name not in m.scopes: # strict delegation: never more than the principal holds
|
|
452
|
+
raise _Deny("delegation", f"principal {m.principal!r} lacks scope {contract.name!r}")
|
|
453
|
+
charge = float(params[contract.budget_param].value) if contract.budget_param else 0.0
|
|
454
|
+
if m.budget is not None and self._spent.get(m.jti, 0.0) + charge > m.budget:
|
|
455
|
+
raise _Deny("budget", f"mandate budget {m.budget} would be exceeded")
|
|
456
|
+
if m.max_calls is not None and self._calls.get(m.jti, 0) >= m.max_calls:
|
|
457
|
+
raise _Deny("budget", f"mandate allows at most {m.max_calls} calls")
|
|
458
|
+
|
|
459
|
+
values = {k: t.value for k, t in params.items()}
|
|
460
|
+
context = {k: provider(contract.name, values, m) for k, provider in pack.context.items()}
|
|
461
|
+
intent = ResolvedIntent(contract.name, params, m, context)
|
|
462
|
+
decision = pack.pdp.evaluate(intent, contract)
|
|
463
|
+
|
|
464
|
+
if decision.effect is Effect.DENY:
|
|
465
|
+
return Outcome(Status.DENIED, decision), intent
|
|
466
|
+
op = intent.op_hash()
|
|
467
|
+
approvers: list[tuple[str, Mapping]] = []
|
|
468
|
+
if decision.effect in (Effect.STEP_UP, Effect.FOUR_EYES):
|
|
469
|
+
for a in approvals:
|
|
470
|
+
human = self.approvals.check(a, op)
|
|
471
|
+
ok = human == m.principal if decision.effect is Effect.STEP_UP else (human and human != m.principal)
|
|
472
|
+
if ok:
|
|
473
|
+
approvers.append((human, a))
|
|
474
|
+
break
|
|
475
|
+
if not approvers:
|
|
476
|
+
return Outcome(Status.PENDING, decision, op), intent
|
|
477
|
+
|
|
478
|
+
executor = pack.executors.get(contract.name)
|
|
479
|
+
if executor is None:
|
|
480
|
+
raise _Deny("execution", f"no executor for {contract.name!r}")
|
|
481
|
+
# write-ahead receipt: nothing executes unless its intent is on record first (if the ledger fails,
|
|
482
|
+
# this raises and the proposal is DENIED); after a crash, an "EXECUTING" entry with no final receipt
|
|
483
|
+
# names an operation to reconcile with the system acted upon
|
|
484
|
+
self.ledger.append(action=contract.name, status="EXECUTING", op_hash=op, intent=intent.canonical(),
|
|
485
|
+
approvers=[h for h, _ in approvers])
|
|
486
|
+
result = executor(values, m) # only the gateway holds the credential to act
|
|
487
|
+
for _, a in approvers:
|
|
488
|
+
self.approvals.consume(a)
|
|
489
|
+
self._spent[m.jti] = self._spent.get(m.jti, 0.0) + charge
|
|
490
|
+
self._calls[m.jti] = self._calls.get(m.jti, 0) + 1
|
|
491
|
+
return Outcome(Status.EXECUTED, decision, op, result, tuple(h for h, _ in approvers)), intent
|
|
492
|
+
|
|
493
|
+
# -------------------------------------------------------------------- #
|
|
494
|
+
@staticmethod
|
|
495
|
+
def _coerce(p: Param, value: Any) -> Any:
|
|
496
|
+
if p.type is float and isinstance(value, int) and not isinstance(value, bool):
|
|
497
|
+
value = float(value)
|
|
498
|
+
if isinstance(value, bool) and p.type is not bool or not isinstance(value, p.type):
|
|
499
|
+
raise _Deny("contract", f"{p.name}: expected {p.type.__name__}")
|
|
500
|
+
if p.validate is not None and not p.validate(value):
|
|
501
|
+
raise _Deny("contract", f"{p.name}: failed validation")
|
|
502
|
+
return value
|
|
503
|
+
|
|
504
|
+
def _resolve(self, p: Param, raw: Any, m: Mandate, pack: Pack) -> Tagged:
|
|
505
|
+
"""Turn a raw agent value into a Tagged value using trusted sources only."""
|
|
506
|
+
if p.each:
|
|
507
|
+
if not isinstance(raw, list):
|
|
508
|
+
raise _Deny("contract", f"{p.name}: expected a list")
|
|
509
|
+
items = [self._resolve(replace(p, each=False), item, m, pack) for item in raw]
|
|
510
|
+
origins = {t.origin for t in items}
|
|
511
|
+
origin = next((o for o in (Origin.UNTRUSTED, Origin.USER_CHANNEL) if o in origins), Origin.SYSTEM_OF_RECORD)
|
|
512
|
+
return Tagged([t.value for t in items], origin, ",".join(t.ref or "" for t in items) or None)
|
|
513
|
+
norm = p.normalize or (lambda v: v)
|
|
514
|
+
for src in p.sources:
|
|
515
|
+
if isinstance(src, FromMandate):
|
|
516
|
+
if src.field in m.claims and not isinstance(raw, Mapping):
|
|
517
|
+
claims = m.claims[src.field]
|
|
518
|
+
for claim in claims if isinstance(claims, list) else [claims]:
|
|
519
|
+
try:
|
|
520
|
+
same = norm(raw) == norm(claim)
|
|
521
|
+
except Exception: # noqa: BLE001
|
|
522
|
+
same = False
|
|
523
|
+
if same:
|
|
524
|
+
return Tagged(self._coerce(p, claim), Origin.USER_CHANNEL, f"mandate:{src.field}")
|
|
525
|
+
elif isinstance(src, FromRegistry):
|
|
526
|
+
reg = pack.registries[src.registry]
|
|
527
|
+
if isinstance(raw, Mapping):
|
|
528
|
+
ref = raw.get("ref") if set(raw) == {"ref"} and isinstance(raw.get("ref"), str) else None
|
|
529
|
+
else:
|
|
530
|
+
ref = reg.find(m.owner, raw)
|
|
531
|
+
record = reg.get(m.owner, ref) if ref is not None else None
|
|
532
|
+
if record is not None:
|
|
533
|
+
return Tagged(self._coerce(p, record), Origin.SYSTEM_OF_RECORD, f"{src.registry}/{ref}")
|
|
534
|
+
else: # Free
|
|
535
|
+
return Tagged(self._coerce(p, raw), Origin.UNTRUSTED)
|
|
536
|
+
allowed = ", ".join(f"mandate:{s.field}" if isinstance(s, FromMandate) else f"registry:{s.registry}"
|
|
537
|
+
for s in p.sources)
|
|
538
|
+
raise _Deny("provenance", f"{p.name}: value not found in any trusted source ({allowed})")
|
|
File without changes
|