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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Accounts-payable pack. Default deny; forbid overrides permit at every approval level.
|
|
2
|
+
// Amounts are integer cents. approval_level: 0 none, 1 step-up (the accountant), 2 four-eyes (e.g. the CFO).
|
|
3
|
+
// The vendor master is itself a protected resource: changing it is an intent, and a freshly changed
|
|
4
|
+
// IBAN is less trusted than a stable one. The ERP invoice is the source of truth for vendor and amount.
|
|
5
|
+
|
|
6
|
+
// --- vendor.update_bank_details: only after a call-back on the phone number the ERP already knew,
|
|
7
|
+
// and always with a second human.
|
|
8
|
+
@id("forbid-bank-change-without-callback")
|
|
9
|
+
forbid (principal, action == Action::"vendor.update_bank_details", resource)
|
|
10
|
+
unless { context.callback_verified };
|
|
11
|
+
|
|
12
|
+
@id("permit-bank-change-four-eyes")
|
|
13
|
+
permit (principal, action == Action::"vendor.update_bank_details", resource)
|
|
14
|
+
when { context.approval_level >= 2 };
|
|
15
|
+
|
|
16
|
+
// --- invoice.pay
|
|
17
|
+
@id("forbid-invoice-not-open")
|
|
18
|
+
forbid (principal, action == Action::"invoice.pay", resource)
|
|
19
|
+
unless { context.invoice_open };
|
|
20
|
+
|
|
21
|
+
@id("forbid-payee-not-invoice-vendor")
|
|
22
|
+
forbid (principal, action == Action::"invoice.pay", resource)
|
|
23
|
+
unless { context.payee_is_invoice_vendor };
|
|
24
|
+
|
|
25
|
+
@id("forbid-amount-differs-from-invoice")
|
|
26
|
+
forbid (principal, action == Action::"invoice.pay", resource)
|
|
27
|
+
unless { context.amount_matches_invoice };
|
|
28
|
+
|
|
29
|
+
@id("forbid-verification-of-payee-mismatch")
|
|
30
|
+
forbid (principal, action == Action::"invoice.pay", resource)
|
|
31
|
+
unless { context.vop_match };
|
|
32
|
+
|
|
33
|
+
@id("forbid-sanctioned-payee")
|
|
34
|
+
forbid (principal, action == Action::"invoice.pay", resource)
|
|
35
|
+
when { context.payee_sanctioned };
|
|
36
|
+
|
|
37
|
+
@id("forbid-hard-cap")
|
|
38
|
+
forbid (principal, action == Action::"invoice.pay", resource)
|
|
39
|
+
when { context.params.amount_cents > 5000000 };
|
|
40
|
+
|
|
41
|
+
// stable IBAN (changed 7+ days ago): up to 5,000.00 automatic, above that the accountant confirms
|
|
42
|
+
@id("permit-pay-stable-iban")
|
|
43
|
+
permit (principal, action == Action::"invoice.pay", resource)
|
|
44
|
+
when { context.iban_age_days >= 7 && context.params.amount_cents <= 500000 };
|
|
45
|
+
|
|
46
|
+
@id("permit-pay-stable-iban-large-with-step-up")
|
|
47
|
+
permit (principal, action == Action::"invoice.pay", resource)
|
|
48
|
+
when { context.iban_age_days >= 7 && context.params.amount_cents > 500000 && context.approval_level >= 1 };
|
|
49
|
+
|
|
50
|
+
// cooling-off: IBAN changed less than 7 days ago => four eyes, whatever the amount
|
|
51
|
+
@id("permit-pay-fresh-iban-four-eyes")
|
|
52
|
+
permit (principal, action == Action::"invoice.pay", resource)
|
|
53
|
+
when { context.iban_age_days < 7 && context.approval_level >= 2 };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Accounts-payable pack: the "we changed banks" fraud, on an AP agent that reads vendor emails.
|
|
2
|
+
|
|
3
|
+
Ported from an independent v2.2 design. Systems of record, all per tenant (the company named in the
|
|
4
|
+
signed mandate), read by the gateway, never written by the agent:
|
|
5
|
+
|
|
6
|
+
vendors : vendor id -> {name, iban, iban_changed_at, phone} (ERP vendor master)
|
|
7
|
+
invoices : invoice ref -> {vendor_id, amount_cents, currency, status} (ERP, after PO/receipt/invoice match)
|
|
8
|
+
callbacks : (vendor id, new IBAN) -> phone number the finance team called
|
|
9
|
+
vop : Verification of Payee: (vendor name, IBAN) -> holder name matches
|
|
10
|
+
|
|
11
|
+
Actions:
|
|
12
|
+
vendor.update_bank_details needs a call-back on the phone number already on file, then four eyes.
|
|
13
|
+
invoice.pay the invoice must exist and be open, the payee must be the CURRENT IBAN of the
|
|
14
|
+
invoice's vendor, the amount must equal the invoice; a freshly changed IBAN
|
|
15
|
+
(< 7 days) needs four eyes; large invoices need step-up; VoP and sanctions apply.
|
|
16
|
+
|
|
17
|
+
Execution happens inside the gateway (under its lock): an invoice is marked paid in the same critical
|
|
18
|
+
section that authorised it, so it cannot be paid twice, even by concurrent requests.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from intentplane.cedar_pdp import CedarPDP
|
|
27
|
+
from intentplane.core import ActionContract, FromMandate, FromRegistry, Pack, Param
|
|
28
|
+
from intentplane.packs.bank import is_iban, norm_iban
|
|
29
|
+
|
|
30
|
+
DAY = 86_400.0
|
|
31
|
+
POLICY_FILE = Path(__file__).with_name("accounts_payable.cedar")
|
|
32
|
+
|
|
33
|
+
UPDATE_BANK = ActionContract(
|
|
34
|
+
name="vendor.update_bank_details", resource="vendor", version="ap-1",
|
|
35
|
+
params=(
|
|
36
|
+
Param("vendor_id", str, critical=True, sources=(FromRegistry("vendors"),)),
|
|
37
|
+
Param("new_iban", str, validate=is_iban), # untrusted by nature: it comes from the request itself
|
|
38
|
+
Param("reason", str),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
PAY = ActionContract(
|
|
43
|
+
name="invoice.pay", resource="vendor", version="ap-1", budget_param="amount_cents",
|
|
44
|
+
params=(
|
|
45
|
+
Param("source_account", str, critical=True, sources=(FromMandate("source_account"),), normalize=norm_iban,
|
|
46
|
+
validate=is_iban),
|
|
47
|
+
Param("invoice_ref", str, critical=True, sources=(FromRegistry("invoices"),)),
|
|
48
|
+
Param("payee_iban", str, critical=True, sources=(FromRegistry("vendor_ibans"),), normalize=norm_iban,
|
|
49
|
+
validate=is_iban),
|
|
50
|
+
Param("amount_cents", int, validate=lambda a: a > 0),
|
|
51
|
+
Param("currency", str, critical=True, sources=(FromMandate("currency"),)),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ERP:
|
|
57
|
+
"""Fictional ERP: tenant -> vendors, invoices, call-back log, plus a VoP service and a sanctions list."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, tenants: Mapping[str, Mapping[str, dict]], vop: Mapping[tuple[str, str], bool] | None = None,
|
|
60
|
+
sanctioned: Iterable[str] = (), now: Callable[[], float] = time.time) -> None:
|
|
61
|
+
self.t = {name: {"vendors": dict(d.get("vendors", {})), "invoices": dict(d.get("invoices", {})),
|
|
62
|
+
"callbacks": dict(d.get("callbacks", {}))} for name, d in tenants.items()}
|
|
63
|
+
self.vop = {(n, norm_iban(i)): ok for (n, i), ok in (vop or {}).items()}
|
|
64
|
+
self.sanctioned = {norm_iban(i) for i in sanctioned}
|
|
65
|
+
self.now = now
|
|
66
|
+
self.payments: list[dict] = []
|
|
67
|
+
|
|
68
|
+
def of(self, tenant: str) -> dict:
|
|
69
|
+
return self.t.get(tenant, {"vendors": {}, "invoices": {}, "callbacks": {}})
|
|
70
|
+
|
|
71
|
+
def log_callback(self, tenant: str, vendor_id: str, new_iban: str, phone: str) -> None:
|
|
72
|
+
"""Finance records that it called the vendor about `new_iban` on `phone` (trusted side only). The IBAN is
|
|
73
|
+
normalised here, so a connector cannot store a spelling the gateway would not recognise."""
|
|
74
|
+
self.t[tenant]["callbacks"][(vendor_id, norm_iban(new_iban))] = phone
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Ids:
|
|
78
|
+
"""Registry view: ref == value, valid if the record exists for that tenant."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, erp: ERP, table: str) -> None:
|
|
81
|
+
self.erp, self.table = erp, table
|
|
82
|
+
|
|
83
|
+
def get(self, owner, ref):
|
|
84
|
+
return ref if ref in self.erp.of(owner)[self.table] else None
|
|
85
|
+
|
|
86
|
+
def find(self, owner, value):
|
|
87
|
+
return value if isinstance(value, str) and value in self.erp.of(owner)[self.table] else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _CurrentIbans:
|
|
91
|
+
"""Registry view: the CURRENT IBAN of each vendor of the tenant (ref = vendor id)."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, erp: ERP) -> None:
|
|
94
|
+
self.erp = erp
|
|
95
|
+
|
|
96
|
+
def get(self, owner, ref):
|
|
97
|
+
v = self.erp.of(owner)["vendors"].get(ref)
|
|
98
|
+
return v["iban"] if v else None
|
|
99
|
+
|
|
100
|
+
def find(self, owner, value):
|
|
101
|
+
try:
|
|
102
|
+
n = norm_iban(value)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return None
|
|
105
|
+
return next((vid for vid, v in self.erp.of(owner)["vendors"].items() if norm_iban(v["iban"]) == n), None)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build(erp: ERP) -> Pack:
|
|
109
|
+
def invoice(values, m) -> dict:
|
|
110
|
+
return erp.of(m.owner)["invoices"][values["invoice_ref"]]
|
|
111
|
+
|
|
112
|
+
def invoice_vendor(values, m) -> dict:
|
|
113
|
+
return erp.of(m.owner)["vendors"][invoice(values, m)["vendor_id"]]
|
|
114
|
+
|
|
115
|
+
def callback_verified(a, v, m) -> bool:
|
|
116
|
+
if a != UPDATE_BANK.name:
|
|
117
|
+
return False
|
|
118
|
+
t = erp.of(m.owner)
|
|
119
|
+
phone = t["callbacks"].get((v["vendor_id"], norm_iban(v["new_iban"])))
|
|
120
|
+
return phone is not None and phone == t["vendors"][v["vendor_id"]]["phone"]
|
|
121
|
+
|
|
122
|
+
def pay_fact(fn):
|
|
123
|
+
return lambda a, v, m: fn(v, m) if a == PAY.name else False
|
|
124
|
+
|
|
125
|
+
context = {
|
|
126
|
+
"callback_verified": callback_verified,
|
|
127
|
+
"invoice_open": pay_fact(lambda v, m: invoice(v, m)["status"] == "open"),
|
|
128
|
+
"payee_is_invoice_vendor": pay_fact(lambda v, m: norm_iban(invoice_vendor(v, m)["iban"])
|
|
129
|
+
== norm_iban(v["payee_iban"])),
|
|
130
|
+
"amount_matches_invoice": pay_fact(lambda v, m: invoice(v, m)["amount_cents"] == v["amount_cents"]
|
|
131
|
+
and invoice(v, m)["currency"] == v["currency"]),
|
|
132
|
+
"vop_match": pay_fact(lambda v, m: erp.vop.get((invoice_vendor(v, m)["name"], norm_iban(v["payee_iban"])),
|
|
133
|
+
False)),
|
|
134
|
+
"payee_sanctioned": pay_fact(lambda v, m: norm_iban(v["payee_iban"]) in erp.sanctioned),
|
|
135
|
+
"iban_age_days": lambda a, v, m: (int((erp.now() - invoice_vendor(v, m)["iban_changed_at"]) // DAY)
|
|
136
|
+
if a == PAY.name else 0),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def update_bank(values, m) -> str: # writes only in the mandate's own tenant
|
|
140
|
+
vendor = erp.of(m.owner)["vendors"][values["vendor_id"]]
|
|
141
|
+
vendor["iban"], vendor["iban_changed_at"] = norm_iban(values["new_iban"]), erp.now()
|
|
142
|
+
return f"vendor {values['vendor_id']}: IBAN updated"
|
|
143
|
+
|
|
144
|
+
def pay(values, m) -> str:
|
|
145
|
+
erp.of(m.owner)["invoices"][values["invoice_ref"]]["status"] = "paid"
|
|
146
|
+
erp.payments.append({**values, "tenant": m.owner})
|
|
147
|
+
return f"paid {values['amount_cents'] / 100:.2f} {values['currency']} for {values['invoice_ref']}"
|
|
148
|
+
|
|
149
|
+
return Pack("accounts_payable", [UPDATE_BANK, PAY], CedarPDP.from_file("ap-policy-1", POLICY_FILE),
|
|
150
|
+
registries={"vendors": _Ids(erp, "vendors"), "invoices": _Ids(erp, "invoices"),
|
|
151
|
+
"vendor_ibans": _CurrentIbans(erp)},
|
|
152
|
+
context=context, executors={UPDATE_BANK.name: update_bank, PAY.name: pay})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Bank pack. Default deny; forbid overrides permit at every approval level.
|
|
2
|
+
// Amounts are integer cents. approval_level: 0 none, 1 step-up (the principal), 2 four-eyes.
|
|
3
|
+
|
|
4
|
+
@id("forbid-sanctioned-payee")
|
|
5
|
+
forbid (principal, action == Action::"payment.transfer", resource)
|
|
6
|
+
when { context.payee_sanctioned };
|
|
7
|
+
|
|
8
|
+
@id("forbid-hard-cap")
|
|
9
|
+
forbid (principal, action == Action::"payment.transfer", resource)
|
|
10
|
+
when { context.params.amount_cents > 1000000 };
|
|
11
|
+
|
|
12
|
+
@id("permit-transfer")
|
|
13
|
+
permit (principal, action == Action::"payment.transfer", resource)
|
|
14
|
+
when { context.params.amount_cents <= 100000 };
|
|
15
|
+
|
|
16
|
+
@id("permit-large-transfer-with-step-up")
|
|
17
|
+
permit (principal, action == Action::"payment.transfer", resource)
|
|
18
|
+
when { context.params.amount_cents > 100000 && context.approval_level >= 1 };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Bank pack (the AgenticZeroTrust behaviour on the generic core).
|
|
2
|
+
|
|
3
|
+
payee: the one the user named in the mandate, or one of HIS registered beneficiaries.
|
|
4
|
+
source account and currency: bound in the mandate. Amount: chosen by the agent, but charged
|
|
5
|
+
against the mandate's cumulative budget and checked by Cedar (step-up above 1,000.00, hard cap
|
|
6
|
+
10,000.00, see bank.cedar). Sanctions come from the gateway's own list, never from the agent.
|
|
7
|
+
Amounts are integer cents (Cedar has no float).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from intentplane.cedar_pdp import CedarPDP
|
|
16
|
+
from intentplane.core import ActionContract, DictRegistry, FromMandate, FromRegistry, Pack, Param
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def norm_iban(v: object) -> str:
|
|
20
|
+
if not isinstance(v, str) or not v.isascii(): # look-alike characters are not folded: they stay different
|
|
21
|
+
raise ValueError("not an ASCII IBAN")
|
|
22
|
+
return re.sub(r"\s+", "", v).upper()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_IBAN = re.compile(r"^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_iban(v: object) -> bool:
|
|
29
|
+
"""Structural IBAN check (country, check digits, BBAN). Applied to the RESOLVED value, so even a record
|
|
30
|
+
a human added by mistake ("Apple") is refused."""
|
|
31
|
+
try:
|
|
32
|
+
return bool(_IBAN.match(norm_iban(v)))
|
|
33
|
+
except ValueError:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
TRANSFER = ActionContract(
|
|
38
|
+
name="payment.transfer", resource="account", version="bank-3", budget_param="amount_cents",
|
|
39
|
+
params=(
|
|
40
|
+
Param("source_account", str, critical=True, sources=(FromMandate("source_account"),), normalize=norm_iban,
|
|
41
|
+
validate=is_iban),
|
|
42
|
+
Param("payee_iban", str, critical=True, sources=(FromMandate("payee"), FromRegistry("beneficiaries")),
|
|
43
|
+
normalize=norm_iban, validate=is_iban),
|
|
44
|
+
Param("amount_cents", int, validate=lambda a: a > 0),
|
|
45
|
+
Param("currency", str, critical=True, sources=(FromMandate("currency"),)),
|
|
46
|
+
Param("reference", str),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
POLICY_FILE = Path(__file__).with_name("bank.cedar")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build(beneficiaries: Mapping[str, Mapping[str, str]], sanctioned: Iterable[str] = (),
|
|
54
|
+
sanctioned_countries: Iterable[str] = ("KP", "IR")) -> tuple[Pack, list]:
|
|
55
|
+
"""beneficiaries: principal -> {ref: IBAN}. Returns the pack and the (mock) core-banking ledger."""
|
|
56
|
+
blocked = {norm_iban(i) for i in sanctioned}
|
|
57
|
+
countries = set(sanctioned_countries)
|
|
58
|
+
ledger: list[dict] = []
|
|
59
|
+
|
|
60
|
+
def is_sanctioned(action, values, mandate) -> bool:
|
|
61
|
+
iban = norm_iban(values["payee_iban"])
|
|
62
|
+
return iban in blocked or iban[:2] in countries
|
|
63
|
+
|
|
64
|
+
def execute(values, mandate) -> str:
|
|
65
|
+
ledger.append(dict(values))
|
|
66
|
+
return f"transfer-{len(ledger)}"
|
|
67
|
+
|
|
68
|
+
pack = Pack("bank", [TRANSFER], CedarPDP.from_file("bank-policy-3", POLICY_FILE),
|
|
69
|
+
registries={"beneficiaries": DictRegistry(beneficiaries, normalize=norm_iban)},
|
|
70
|
+
context={"payee_sanctioned": is_sanctioned},
|
|
71
|
+
executors={"payment.transfer": execute})
|
|
72
|
+
return pack, ledger
|
|
73
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Code pack. Default deny; forbid overrides permit at every approval level.
|
|
2
|
+
// approval_level: 0 none, 1 step-up (the developer), 2 four-eyes (a reviewer).
|
|
3
|
+
|
|
4
|
+
@id("forbid-red-ci")
|
|
5
|
+
forbid (principal, action == Action::"repo.merge", resource)
|
|
6
|
+
when { context.ci_status != "success" };
|
|
7
|
+
|
|
8
|
+
@id("permit-merge-unprotected-branch")
|
|
9
|
+
permit (principal, action == Action::"repo.merge", resource)
|
|
10
|
+
unless { ["main", "release"].contains(context.params.target_branch) };
|
|
11
|
+
|
|
12
|
+
@id("permit-merge-protected-branch-reviewed")
|
|
13
|
+
permit (principal, action == Action::"repo.merge", resource)
|
|
14
|
+
when { ["main", "release"].contains(context.params.target_branch) && context.approved_review };
|
|
15
|
+
|
|
16
|
+
@id("permit-merge-protected-branch-four-eyes")
|
|
17
|
+
permit (principal, action == Action::"repo.merge", resource)
|
|
18
|
+
when { ["main", "release"].contains(context.params.target_branch) && context.approval_level >= 2 };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Code pack: the repository is bound in the developer's mandate, the commit must be one CI attested
|
|
2
|
+
for that developer's repos (read by the gateway), CI status and review state are read by the gateway
|
|
3
|
+
from the CI and code-review systems, protected branches without an approved review need a second
|
|
4
|
+
human. No free shell exists in the catalog at all."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from intentplane.cedar_pdp import CedarPDP
|
|
11
|
+
from intentplane.core import (
|
|
12
|
+
ActionContract,
|
|
13
|
+
DictRegistry,
|
|
14
|
+
FromMandate,
|
|
15
|
+
FromRegistry,
|
|
16
|
+
Pack,
|
|
17
|
+
Param,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
MERGE = ActionContract(
|
|
21
|
+
name="repo.merge", resource="repo", version="code-3",
|
|
22
|
+
params=(
|
|
23
|
+
Param("repo", str, critical=True, sources=(FromMandate("repo"),)),
|
|
24
|
+
Param("head_sha", str, critical=True, sources=(FromRegistry("ci_attested"),), validate=lambda s: len(s) == 40),
|
|
25
|
+
Param("target_branch", str, validate=lambda b: bool(b)),
|
|
26
|
+
Param("message", str),
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
PDP = CedarPDP.from_file("code-policy-3", Path(__file__).with_name("code.cedar"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build(ci_attested: Mapping[str, Mapping[str, str]], ci_status: Mapping[str, str],
|
|
35
|
+
approved_reviews: Iterable[str] = ()) -> tuple[Pack, list]:
|
|
36
|
+
"""ci_attested: developer -> {CI run id: sha}; ci_status: sha -> status. Returns the pack and the merge log."""
|
|
37
|
+
approved = set(approved_reviews)
|
|
38
|
+
merges: list[dict] = []
|
|
39
|
+
|
|
40
|
+
def execute(values, mandate) -> str:
|
|
41
|
+
merges.append(dict(values))
|
|
42
|
+
return f"merged {values['head_sha'][:7]} into {values['target_branch']}"
|
|
43
|
+
|
|
44
|
+
pack = Pack("code", [MERGE], PDP,
|
|
45
|
+
registries={"ci_attested": DictRegistry(ci_attested)},
|
|
46
|
+
context={"ci_status": lambda a, v, m: ci_status.get(v["head_sha"], "unknown"),
|
|
47
|
+
"approved_review": lambda a, v, m: v["head_sha"] in approved},
|
|
48
|
+
executors={"repo.merge": execute})
|
|
49
|
+
return pack, merges
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Health pack. Default deny; forbid overrides permit at every approval level.
|
|
2
|
+
// approval_level: 0 none, 1 step-up (the clinician justifies), 2 four-eyes (second reviewer).
|
|
3
|
+
|
|
4
|
+
@id("forbid-research-without-consent")
|
|
5
|
+
forbid (principal, action == Action::"ehr.read_record", resource)
|
|
6
|
+
when { context.params.purpose == "research" && !context.consent_research };
|
|
7
|
+
|
|
8
|
+
@id("permit-treatment-read")
|
|
9
|
+
permit (principal, action == Action::"ehr.read_record", resource)
|
|
10
|
+
when { context.params.purpose == "treatment" && context.params.section != "full" };
|
|
11
|
+
|
|
12
|
+
@id("permit-full-record-with-step-up")
|
|
13
|
+
permit (principal, action == Action::"ehr.read_record", resource)
|
|
14
|
+
when { context.params.purpose == "treatment" && context.params.section == "full" && context.approval_level >= 1 };
|
|
15
|
+
|
|
16
|
+
@id("permit-research-read")
|
|
17
|
+
permit (principal, action == Action::"ehr.read_record", resource)
|
|
18
|
+
when { context.params.purpose == "research" && context.params.section != "full" };
|
|
19
|
+
|
|
20
|
+
@id("permit-break-the-glass-four-eyes")
|
|
21
|
+
permit (principal, action == Action::"ehr.read_record", resource)
|
|
22
|
+
when { context.params.purpose == "emergency" && context.approval_level >= 2 };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Health pack: the patient must be one of the clinician's OPEN encounters (read by the gateway from
|
|
2
|
+
the EHR), the purpose is bound in the clinician's mandate, research needs consent on file (read by the
|
|
3
|
+
gateway from the consent registry), full records under treatment need step-up, emergency access is
|
|
4
|
+
four-eyes ("break the glass")."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from intentplane.cedar_pdp import CedarPDP
|
|
11
|
+
from intentplane.core import (
|
|
12
|
+
ActionContract,
|
|
13
|
+
DictRegistry,
|
|
14
|
+
FromMandate,
|
|
15
|
+
FromRegistry,
|
|
16
|
+
Pack,
|
|
17
|
+
Param,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SECTIONS = {"summary", "labs", "meds", "notes", "full"}
|
|
21
|
+
PURPOSES = {"treatment", "emergency", "research"}
|
|
22
|
+
|
|
23
|
+
READ_RECORD = ActionContract(
|
|
24
|
+
name="ehr.read_record", resource="patient_record", version="health-3",
|
|
25
|
+
params=(
|
|
26
|
+
Param("patient_id", str, critical=True, sources=(FromRegistry("open_encounters"),)),
|
|
27
|
+
Param("section", str, validate=lambda s: s in SECTIONS),
|
|
28
|
+
Param("purpose", str, critical=True, sources=(FromMandate("purpose"),), validate=lambda p: p in PURPOSES),
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PDP = CedarPDP.from_file("health-policy-3", Path(__file__).with_name("health.cedar"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build(open_encounters: Mapping[str, Mapping[str, str]], research_consent: Iterable[str] = ()) -> tuple[Pack, list]:
|
|
37
|
+
"""open_encounters: clinician -> {encounter id: patient id}. Returns the pack and the access log."""
|
|
38
|
+
consent = set(research_consent)
|
|
39
|
+
reads: list[dict] = []
|
|
40
|
+
|
|
41
|
+
def execute(values, mandate) -> dict:
|
|
42
|
+
reads.append(dict(values))
|
|
43
|
+
return {"patient_id": values["patient_id"], "section": values["section"], "data": "<record>"}
|
|
44
|
+
|
|
45
|
+
pack = Pack("health", [READ_RECORD], PDP,
|
|
46
|
+
registries={"open_encounters": DictRegistry(open_encounters)},
|
|
47
|
+
context={"consent_research": lambda a, v, m: v["patient_id"] in consent},
|
|
48
|
+
executors={"ehr.read_record": execute})
|
|
49
|
+
return pack, reads
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""intentplane as an MCP proxy: put the gateway between any MCP client and the MCP servers it uses.
|
|
2
|
+
|
|
3
|
+
intentplane-mcp --config intentplane.toml
|
|
4
|
+
|
|
5
|
+
The client (Claude Desktop, Claude Code, Cursor, ...) sees the downstream servers' tools. Every call to a
|
|
6
|
+
configured tool becomes a proposal: its critical arguments are resolved against the mandate and the
|
|
7
|
+
registries of the config, the Cedar policy decides, a step-up is confirmed by the user through MCP
|
|
8
|
+
elicitation, and only then does the proxy call the downstream tool, with the resolved values.
|
|
9
|
+
"""
|
|
10
|
+
from .config import ProxyConfig, load_config
|
|
11
|
+
from .gate import GateResult, ToolGate
|
|
12
|
+
|
|
13
|
+
__all__ = ["GateResult", "ProxyConfig", "ToolGate", "load_config"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Proxy configuration (TOML). Relative paths are resolved against the config file's directory.
|
|
2
|
+
|
|
3
|
+
[mandate] # the session mandate, issued by the trusted side when the proxy starts
|
|
4
|
+
principal = "emma"
|
|
5
|
+
budget = 250000 # in the units of each tool's `budget_param` (e.g. cents)
|
|
6
|
+
[mandate.claims]
|
|
7
|
+
payee = ["DE89370400440532013000"]
|
|
8
|
+
|
|
9
|
+
[registries.beneficiaries] # a system of record: {ref: value}, read for the mandate's owner
|
|
10
|
+
file = "beneficiaries.json"
|
|
11
|
+
normalize = "iban"
|
|
12
|
+
|
|
13
|
+
[[servers]] # downstream MCP servers (stdio)
|
|
14
|
+
name = "bank"
|
|
15
|
+
command = "python"
|
|
16
|
+
args = ["bank_server.py"]
|
|
17
|
+
|
|
18
|
+
[[tools]] # a tool the gateway decides on
|
|
19
|
+
server = "bank"
|
|
20
|
+
tool = "send_money"
|
|
21
|
+
budget_param = "amount"
|
|
22
|
+
[tools.params.recipient]
|
|
23
|
+
critical = true
|
|
24
|
+
sources = ["mandate:payee", "registry:beneficiaries"]
|
|
25
|
+
normalize = "iban"
|
|
26
|
+
[tools.params.amount]
|
|
27
|
+
type = "number"
|
|
28
|
+
scale = 100 # Cedar has no floats: the policy sees round(amount * 100)
|
|
29
|
+
|
|
30
|
+
[policy]
|
|
31
|
+
file = "policy.cedar"
|
|
32
|
+
|
|
33
|
+
[defaults]
|
|
34
|
+
passthrough = ["bank.get_balance"] # read-only tools that need no decision; anything else unlisted is DENIED
|
|
35
|
+
receipts = "receipts.jsonl"
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import sys
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
if sys.version_info >= (3, 11):
|
|
46
|
+
import tomllib
|
|
47
|
+
else: # pragma: no cover
|
|
48
|
+
import tomli as tomllib
|
|
49
|
+
|
|
50
|
+
TYPES = {"string": str, "integer": int, "number": int, "boolean": bool} # numbers are scaled to integers
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ParamSpec:
|
|
55
|
+
name: str
|
|
56
|
+
type: str = "string"
|
|
57
|
+
critical: bool = False
|
|
58
|
+
sources: tuple[str, ...] = ()
|
|
59
|
+
normalize: str | None = None
|
|
60
|
+
scale: int = 1
|
|
61
|
+
each: bool = False # a list of values, every item resolved (e.g. email recipients)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ToolSpec:
|
|
66
|
+
server: str
|
|
67
|
+
tool: str
|
|
68
|
+
action: str
|
|
69
|
+
params: tuple[ParamSpec, ...]
|
|
70
|
+
budget_param: str | None = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def key(self) -> str:
|
|
74
|
+
return f"{self.server}.{self.tool}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ServerSpec:
|
|
79
|
+
name: str
|
|
80
|
+
command: str
|
|
81
|
+
args: tuple[str, ...] = ()
|
|
82
|
+
env: dict[str, str] | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ProxyConfig:
|
|
87
|
+
principal: str
|
|
88
|
+
claims: dict[str, Any]
|
|
89
|
+
budget: float | None
|
|
90
|
+
registries: dict[str, tuple[dict[str, Any], str | None]] # name -> (records, normalizer)
|
|
91
|
+
servers: list[ServerSpec]
|
|
92
|
+
tools: dict[str, ToolSpec]
|
|
93
|
+
policy: str
|
|
94
|
+
passthrough: frozenset[str] = frozenset()
|
|
95
|
+
receipts: Path | None = None
|
|
96
|
+
base: Path = field(default_factory=Path.cwd)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _param(name: str, raw: dict) -> ParamSpec:
|
|
100
|
+
spec = ParamSpec(name, raw.get("type", "string"), bool(raw.get("critical", False)),
|
|
101
|
+
tuple(raw.get("sources", ())), raw.get("normalize"), int(raw.get("scale", 1)),
|
|
102
|
+
bool(raw.get("each", False)))
|
|
103
|
+
if spec.type not in TYPES:
|
|
104
|
+
raise ValueError(f"param {name!r}: unknown type {spec.type!r}")
|
|
105
|
+
if spec.critical and not spec.sources:
|
|
106
|
+
raise ValueError(f"param {name!r}: a critical parameter needs at least one trusted source")
|
|
107
|
+
for src in spec.sources:
|
|
108
|
+
kind, _, _ = src.partition(":")
|
|
109
|
+
if kind not in ("mandate", "registry"):
|
|
110
|
+
raise ValueError(f"param {name!r}: source {src!r} must be 'mandate:<claim>' or 'registry:<name>'")
|
|
111
|
+
return spec
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_config(path: str | Path) -> ProxyConfig:
|
|
115
|
+
path = Path(path).resolve()
|
|
116
|
+
base = path.parent
|
|
117
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
118
|
+
mandate = raw.get("mandate", {})
|
|
119
|
+
registries = {}
|
|
120
|
+
for name, reg in raw.get("registries", {}).items():
|
|
121
|
+
records = json.loads((base / reg["file"]).read_text(encoding="utf-8"))
|
|
122
|
+
if not isinstance(records, dict):
|
|
123
|
+
raise TypeError(f"registry {name!r}: the file must hold a JSON object {{ref: value}}")
|
|
124
|
+
registries[name] = (records, reg.get("normalize"))
|
|
125
|
+
# command "python" means the interpreter running the proxy, so the downstream server finds the same packages
|
|
126
|
+
servers = [ServerSpec(s["name"], sys.executable if s["command"] == "python" else s["command"],
|
|
127
|
+
tuple(s.get("args", ())), s.get("env")) for s in raw.get("servers", ())]
|
|
128
|
+
names = {s.name for s in servers}
|
|
129
|
+
tools = {}
|
|
130
|
+
for t in raw.get("tools", ()):
|
|
131
|
+
if t["server"] not in names:
|
|
132
|
+
raise ValueError(f"tool {t['tool']!r}: unknown server {t['server']!r}")
|
|
133
|
+
spec = ToolSpec(t["server"], t["tool"], t.get("action", f"{t['server']}.{t['tool']}"),
|
|
134
|
+
tuple(_param(n, p) for n, p in t.get("params", {}).items()), t.get("budget_param"))
|
|
135
|
+
if spec.budget_param and spec.budget_param not in {p.name for p in spec.params}:
|
|
136
|
+
raise ValueError(f"tool {spec.key}: budget_param {spec.budget_param!r} must be a declared param")
|
|
137
|
+
tools[spec.key] = spec
|
|
138
|
+
policy_file = raw.get("policy", {}).get("file")
|
|
139
|
+
if not policy_file:
|
|
140
|
+
raise ValueError("a [policy] file is required (Cedar, default deny)")
|
|
141
|
+
defaults = raw.get("defaults", {})
|
|
142
|
+
receipts = defaults.get("receipts")
|
|
143
|
+
return ProxyConfig(principal=mandate.get("principal", "user"), claims=dict(mandate.get("claims", {})),
|
|
144
|
+
budget=mandate.get("budget"), registries=registries, servers=servers, tools=tools,
|
|
145
|
+
policy=(base / policy_file).read_text(encoding="utf-8"),
|
|
146
|
+
passthrough=frozenset(defaults.get("passthrough", ())),
|
|
147
|
+
receipts=(base / receipts) if receipts else None, base=base)
|