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.
@@ -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"
@@ -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