ccp-protocol 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ccp/__init__.py +97 -0
- ccp/adapters/__init__.py +5 -0
- ccp/adapters/fastapi.py +97 -0
- ccp/adapters/mcp.py +208 -0
- ccp/agent.py +66 -0
- ccp/audit.py +39 -0
- ccp/canonical.py +192 -0
- ccp/consumer.py +75 -0
- ccp/crypto.py +96 -0
- ccp/decision.py +81 -0
- ccp/errors.py +35 -0
- ccp/hosts.py +52 -0
- ccp/model.py +187 -0
- ccp/requester.py +66 -0
- ccp/runtime.py +200 -0
- ccp/schema.py +134 -0
- ccp/signing.py +14 -0
- ccp/state_machine.py +33 -0
- ccp/stores.py +166 -0
- ccp/stores_sqlite.py +442 -0
- ccp_protocol-0.4.0.dist-info/METADATA +256 -0
- ccp_protocol-0.4.0.dist-info/RECORD +24 -0
- ccp_protocol-0.4.0.dist-info/WHEEL +5 -0
- ccp_protocol-0.4.0.dist-info/top_level.txt +1 -0
ccp/canonical.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Strict canonical JSON per CCP §25.1.
|
|
2
|
+
|
|
3
|
+
The canonical form is deterministic and byte-exact:
|
|
4
|
+
|
|
5
|
+
* numbers: only non-negative integers; floats, exponents, negatives, NaN,
|
|
6
|
+
Infinity and -Infinity are rejected;
|
|
7
|
+
* strings: UTF-8, NFC-normalized (keys and values), minimal escaping;
|
|
8
|
+
* objects: keys other than ``signature`` sorted by Unicode code point;
|
|
9
|
+
duplicate keys rejected; ``signature`` always emitted last;
|
|
10
|
+
* arrays: order preserved; no insignificant whitespace;
|
|
11
|
+
* any JSON value is supported, including top-level scalars.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import unicodedata
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CanonicalError(ValueError):
|
|
20
|
+
"""Raised when a value cannot be represented in canonical form."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, code):
|
|
23
|
+
self.code = code
|
|
24
|
+
super().__init__(code)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_SHORT_ESCAPES = {
|
|
28
|
+
"\b": "\\b",
|
|
29
|
+
"\f": "\\f",
|
|
30
|
+
"\n": "\\n",
|
|
31
|
+
"\r": "\\r",
|
|
32
|
+
"\t": "\\t",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _require_well_formed(text):
|
|
37
|
+
"""Reject strings containing unpaired surrogates (§25.1 UTF-8)."""
|
|
38
|
+
for char in text:
|
|
39
|
+
if 0xD800 <= ord(char) <= 0xDFFF:
|
|
40
|
+
raise CanonicalError("malformed")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def quote(s):
|
|
44
|
+
"""Encode one string using the minimal escape set."""
|
|
45
|
+
_require_well_formed(s)
|
|
46
|
+
s = unicodedata.normalize("NFC", s)
|
|
47
|
+
out = ['"']
|
|
48
|
+
for ch in s:
|
|
49
|
+
if ch == '"':
|
|
50
|
+
out.append('\\"')
|
|
51
|
+
elif ch == "\\":
|
|
52
|
+
out.append("\\\\")
|
|
53
|
+
elif ch in _SHORT_ESCAPES:
|
|
54
|
+
out.append(_SHORT_ESCAPES[ch])
|
|
55
|
+
elif ord(ch) < 0x20:
|
|
56
|
+
out.append("\\u%04x" % ord(ch))
|
|
57
|
+
else:
|
|
58
|
+
out.append(ch)
|
|
59
|
+
out.append('"')
|
|
60
|
+
return "".join(out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def serialize(value, _top=True):
|
|
64
|
+
"""Serialize a parsed JSON value in canonical form."""
|
|
65
|
+
if value is None:
|
|
66
|
+
return "null"
|
|
67
|
+
if value is True:
|
|
68
|
+
return "true"
|
|
69
|
+
if value is False:
|
|
70
|
+
return "false"
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
return quote(value)
|
|
73
|
+
if isinstance(value, int):
|
|
74
|
+
if value < 0:
|
|
75
|
+
raise CanonicalError("negative")
|
|
76
|
+
return str(value)
|
|
77
|
+
if isinstance(value, float):
|
|
78
|
+
raise CanonicalError("float")
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return "[" + ",".join(serialize(v, _top=False) for v in value) + "]"
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
if _top and "signature" in value:
|
|
83
|
+
regular = {k: v for k, v in value.items() if k != "signature"}
|
|
84
|
+
else:
|
|
85
|
+
regular = value
|
|
86
|
+
folded = {}
|
|
87
|
+
for key, item in regular.items():
|
|
88
|
+
nfc = unicodedata.normalize("NFC", key)
|
|
89
|
+
if nfc in folded:
|
|
90
|
+
raise CanonicalError("duplicate")
|
|
91
|
+
folded[nfc] = item
|
|
92
|
+
parts = [quote(k) + ":" + serialize(folded[k], _top=False)
|
|
93
|
+
for k in sorted(folded)]
|
|
94
|
+
if _top and "signature" in value:
|
|
95
|
+
parts.append(quote("signature") + ":"
|
|
96
|
+
+ serialize(value["signature"], _top=False))
|
|
97
|
+
return "{" + ",".join(parts) + "}"
|
|
98
|
+
raise TypeError("unsupported type %r" % (type(value),))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _reject_dupes(pairs):
|
|
102
|
+
seen = {}
|
|
103
|
+
for key, value in pairs:
|
|
104
|
+
if key in seen:
|
|
105
|
+
raise CanonicalError("duplicate")
|
|
106
|
+
seen[key] = value
|
|
107
|
+
return seen
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _reject_constant(_name):
|
|
111
|
+
raise CanonicalError("constant")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_int(text):
|
|
115
|
+
if text.startswith("-"):
|
|
116
|
+
raise CanonicalError("negative")
|
|
117
|
+
return int(text)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_float(text):
|
|
121
|
+
if text.startswith("-"):
|
|
122
|
+
raise CanonicalError("negative")
|
|
123
|
+
raise CanonicalError("float")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check(value):
|
|
127
|
+
"""Validate decoded strings and NFC key uniqueness."""
|
|
128
|
+
if isinstance(value, str):
|
|
129
|
+
_require_well_formed(value)
|
|
130
|
+
elif isinstance(value, list):
|
|
131
|
+
for item in value:
|
|
132
|
+
_check(item)
|
|
133
|
+
elif isinstance(value, dict):
|
|
134
|
+
seen = set()
|
|
135
|
+
for key, item in value.items():
|
|
136
|
+
_require_well_formed(key)
|
|
137
|
+
folded = unicodedata.normalize("NFC", key)
|
|
138
|
+
if folded in seen:
|
|
139
|
+
raise CanonicalError("duplicate")
|
|
140
|
+
seen.add(folded)
|
|
141
|
+
_check(item)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_strict(document):
|
|
145
|
+
"""Parse a JSON document with duplicate-key and NaN/Infinity rejection."""
|
|
146
|
+
try:
|
|
147
|
+
value = json.loads(
|
|
148
|
+
document,
|
|
149
|
+
object_pairs_hook=_reject_dupes,
|
|
150
|
+
parse_constant=_reject_constant,
|
|
151
|
+
parse_int=_parse_int,
|
|
152
|
+
parse_float=_parse_float,
|
|
153
|
+
)
|
|
154
|
+
except json.JSONDecodeError as exc:
|
|
155
|
+
raise CanonicalError("malformed") from exc
|
|
156
|
+
_check(value)
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def canonicalize(document):
|
|
161
|
+
"""Canonicalize a JSON document string."""
|
|
162
|
+
return serialize(parse_strict(document))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def signing_bytes(document):
|
|
166
|
+
"""Canonical bytes signed for a document; ``signature`` excluded."""
|
|
167
|
+
obj = parse_strict(document)
|
|
168
|
+
obj.pop("signature", None)
|
|
169
|
+
return serialize(obj).encode("utf-8")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def signing_bytes_of(value):
|
|
173
|
+
"""Canonical bytes signed for an already-parsed value."""
|
|
174
|
+
obj = dict(value)
|
|
175
|
+
obj.pop("signature", None)
|
|
176
|
+
return serialize(obj).encode("utf-8")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def signed_wire(document, signature_b64url):
|
|
180
|
+
"""Wire form: canonical document with ``signature`` appended last."""
|
|
181
|
+
obj = parse_strict(document)
|
|
182
|
+
obj["signature"] = "ed25519:" + signature_b64url
|
|
183
|
+
return serialize(obj)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def digest_bytes(data):
|
|
187
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def digest_of(document):
|
|
191
|
+
"""`sha256:<hex>` over the canonical signing bytes of a document."""
|
|
192
|
+
return digest_bytes(signing_bytes(document))
|
ccp/consumer.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Consumer role: verify inbound documents and build permission cards."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
|
|
5
|
+
from .errors import CCPError
|
|
6
|
+
from .model import Manifest, Signal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def verify_manifest(document, expected_provider_id=None):
|
|
10
|
+
"""Parse and verify a signed manifest (§8), returning the `Manifest`."""
|
|
11
|
+
manifest = Manifest.parse(document)
|
|
12
|
+
manifest.verify(expected_provider_id)
|
|
13
|
+
return manifest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_signal(document, verifying_key):
|
|
17
|
+
"""Parse and verify a signed signal (§10.1), returning the `Signal`."""
|
|
18
|
+
signal = Signal.parse(document)
|
|
19
|
+
signal.verify(verifying_key)
|
|
20
|
+
return signal
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PermissionCard:
|
|
25
|
+
"""Plain-language permission card for a trusted UI (§11.4)."""
|
|
26
|
+
|
|
27
|
+
who: str
|
|
28
|
+
what: str
|
|
29
|
+
data_read: list
|
|
30
|
+
where: str
|
|
31
|
+
egress: str
|
|
32
|
+
effects: str
|
|
33
|
+
recurrence: str
|
|
34
|
+
revoke: str
|
|
35
|
+
|
|
36
|
+
def to_dict(self):
|
|
37
|
+
return asdict(self)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def permission_card(manifest, intent, *, provider_name=None, data_read=None,
|
|
41
|
+
where=None, effects=None, recurrence="once", revoke=None):
|
|
42
|
+
"""Build the §11.4 card for one intent of a verified manifest."""
|
|
43
|
+
definition = (manifest.intents or {}).get(intent)
|
|
44
|
+
if definition is None:
|
|
45
|
+
raise CCPError("intent_not_allowed", "unknown intent %r" % (intent,))
|
|
46
|
+
|
|
47
|
+
who = provider_name or manifest.provider_name or manifest.provider_id
|
|
48
|
+
placement = definition.get("placement", "local")
|
|
49
|
+
egress_spec = definition.get("data_egress") or {}
|
|
50
|
+
network = egress_spec.get("network") is True
|
|
51
|
+
fields = egress_spec.get("fields") or []
|
|
52
|
+
|
|
53
|
+
if where is None:
|
|
54
|
+
where = ("on this device" if placement == "local"
|
|
55
|
+
else "via %s" % placement)
|
|
56
|
+
if network:
|
|
57
|
+
egress = "sends %s to a remote endpoint" % (
|
|
58
|
+
", ".join(fields) or "declared fields")
|
|
59
|
+
else:
|
|
60
|
+
egress = "does not send data off the device"
|
|
61
|
+
|
|
62
|
+
return PermissionCard(
|
|
63
|
+
who=who,
|
|
64
|
+
what=definition.get("description", intent),
|
|
65
|
+
data_read=list(
|
|
66
|
+
data_read if data_read is not None
|
|
67
|
+
else definition.get("required_capabilities") or []
|
|
68
|
+
),
|
|
69
|
+
where=where,
|
|
70
|
+
egress=egress,
|
|
71
|
+
effects=effects or "no external side effect declared",
|
|
72
|
+
recurrence=("applies to future requests" if recurrence == "future"
|
|
73
|
+
else "applies once"),
|
|
74
|
+
revoke=revoke or "you can revoke this grant at any time",
|
|
75
|
+
)
|
ccp/crypto.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Ed25519 key and signature encoding per CCP §25.2."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
from cryptography.exceptions import InvalidSignature
|
|
6
|
+
from cryptography.hazmat.primitives import serialization
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
8
|
+
Ed25519PrivateKey,
|
|
9
|
+
Ed25519PublicKey,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
KEY_PREFIX = "key:ed25519:"
|
|
13
|
+
SIG_PREFIX = "ed25519:"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CryptoError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def b64url_encode(raw):
|
|
21
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def b64url_decode(text):
|
|
25
|
+
pad = "=" * (-len(text) % 4)
|
|
26
|
+
return base64.urlsafe_b64decode(text + pad)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def key_id_from_public_bytes(raw):
|
|
30
|
+
return KEY_PREFIX + b64url_encode(raw)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def public_bytes_from_key_id(key_id):
|
|
34
|
+
if not key_id.startswith(KEY_PREFIX):
|
|
35
|
+
raise CryptoError("unsupported key identifier: %r" % (key_id,))
|
|
36
|
+
return b64url_decode(key_id[len(KEY_PREFIX):])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SigningKey:
|
|
40
|
+
"""A 32-byte RFC 8032 seed and its Ed25519 signer."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, private_key):
|
|
43
|
+
self._key = private_key
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def generate(cls):
|
|
47
|
+
return cls(Ed25519PrivateKey.generate())
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_seed(cls, seed):
|
|
51
|
+
if len(seed) != 32:
|
|
52
|
+
raise CryptoError("seed must be 32 bytes")
|
|
53
|
+
return cls(Ed25519PrivateKey.from_private_bytes(seed))
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def public_bytes(self):
|
|
57
|
+
return self._key.public_key().public_bytes(
|
|
58
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def key_id(self):
|
|
62
|
+
return key_id_from_public_bytes(self.public_bytes)
|
|
63
|
+
|
|
64
|
+
def verifying_key(self):
|
|
65
|
+
return VerifyingKey(self._key.public_key())
|
|
66
|
+
|
|
67
|
+
def sign(self, message):
|
|
68
|
+
"""Return the signature field value `ed25519:<base64url>`."""
|
|
69
|
+
return SIG_PREFIX + b64url_encode(self._key.sign(message))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class VerifyingKey:
|
|
73
|
+
def __init__(self, public_key):
|
|
74
|
+
self._key = public_key
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_key_id(cls, key_id):
|
|
78
|
+
raw = public_bytes_from_key_id(key_id)
|
|
79
|
+
return cls(Ed25519PublicKey.from_public_bytes(raw))
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def key_id(self):
|
|
83
|
+
return key_id_from_public_bytes(
|
|
84
|
+
self._key.public_bytes(
|
|
85
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw))
|
|
86
|
+
|
|
87
|
+
def verify(self, message, signature_field):
|
|
88
|
+
"""Verify an `ed25519:<base64url>` signature over ``message``."""
|
|
89
|
+
if not isinstance(signature_field, str) or not signature_field.startswith(SIG_PREFIX):
|
|
90
|
+
raise CryptoError("unsupported signature encoding")
|
|
91
|
+
raw = b64url_decode(signature_field[len(SIG_PREFIX):])
|
|
92
|
+
try:
|
|
93
|
+
self._key.verify(raw, message)
|
|
94
|
+
return True
|
|
95
|
+
except InvalidSignature:
|
|
96
|
+
return False
|
ccp/decision.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The pure §10.3 validation decision.
|
|
2
|
+
|
|
3
|
+
Separated from the runtime so it can be tested directly against the frozen
|
|
4
|
+
validation vectors without performing side effects. The runtime calls this
|
|
5
|
+
and then applies the side effects (replay marking, limit reservation,
|
|
6
|
+
confirmation, execution) atomically.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import schema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decide(grant, signal, signer, now, seen, counters, *,
|
|
13
|
+
intent_spec=None, params_schema=None, supported_ccp="0.4-draft"):
|
|
14
|
+
"""Return the §10.3 decision: ``authorized``,
|
|
15
|
+
``confirmation_required``, or a rejection code."""
|
|
16
|
+
# 3. protocol compatibility
|
|
17
|
+
if signal.get("ccp") != supported_ccp:
|
|
18
|
+
return "invalid_request"
|
|
19
|
+
|
|
20
|
+
# 4. locate the grant
|
|
21
|
+
if grant is None:
|
|
22
|
+
return "no_matching_grant"
|
|
23
|
+
|
|
24
|
+
# 2. authenticate the sender against the grant's requester
|
|
25
|
+
if signer != grant.requester_key:
|
|
26
|
+
return "unauthenticated"
|
|
27
|
+
if signal.get("grant_digest") != grant.manifest_digest:
|
|
28
|
+
return "no_matching_grant"
|
|
29
|
+
|
|
30
|
+
# 5. grant state and expiry (explicit lifecycle states first)
|
|
31
|
+
if grant.state == "revoked":
|
|
32
|
+
return "grant_revoked"
|
|
33
|
+
if grant.state == "paused":
|
|
34
|
+
return "grant_paused"
|
|
35
|
+
if grant.state == "suspended":
|
|
36
|
+
return "grant_suspended"
|
|
37
|
+
if grant.state == "expired" or now >= grant.expires_at:
|
|
38
|
+
return "grant_expired"
|
|
39
|
+
|
|
40
|
+
# 6. intent enabled
|
|
41
|
+
intent = signal.get("intent")
|
|
42
|
+
if intent not in grant.intents:
|
|
43
|
+
return "intent_not_allowed"
|
|
44
|
+
|
|
45
|
+
# 7. parameters against the approved schema
|
|
46
|
+
if intent_spec is None or params_schema is None:
|
|
47
|
+
return "capability_unavailable"
|
|
48
|
+
if not schema.is_valid(signal.get("params"), params_schema):
|
|
49
|
+
return "invalid_request"
|
|
50
|
+
|
|
51
|
+
# 8. request expiry and replay
|
|
52
|
+
if now >= signal.get("expires_at", ""):
|
|
53
|
+
return "request_expired"
|
|
54
|
+
if signal.get("request_id") in seen:
|
|
55
|
+
return "replay_detected"
|
|
56
|
+
|
|
57
|
+
# 9. cumulative limits
|
|
58
|
+
for name, maximum in (grant.limits or {}).items():
|
|
59
|
+
if counters.get(name, 0) + 1 > maximum:
|
|
60
|
+
return "limit_exceeded"
|
|
61
|
+
|
|
62
|
+
# 10. required host capabilities
|
|
63
|
+
required = intent_spec.get("required_capabilities", [])
|
|
64
|
+
if not set(required).issubset(set(grant.capabilities or [])):
|
|
65
|
+
return "capability_unavailable"
|
|
66
|
+
|
|
67
|
+
# 11. confirmation policy
|
|
68
|
+
if confirmation_policy(grant, intent_spec, intent) == "per_action":
|
|
69
|
+
return "confirmation_required"
|
|
70
|
+
|
|
71
|
+
return "authorized"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def confirmation_policy(grant, intent_spec, intent_name):
|
|
75
|
+
policy = (grant.confirmation or {}).get(intent_name)
|
|
76
|
+
if policy in ("none", "per_action"):
|
|
77
|
+
return policy
|
|
78
|
+
policy = intent_spec.get("confirmation")
|
|
79
|
+
if policy in ("none", "per_action"):
|
|
80
|
+
return policy
|
|
81
|
+
return "per_action"
|
ccp/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Typed error codes and exceptions for CCP (§10.4)."""
|
|
2
|
+
|
|
3
|
+
AUTHORIZED = "authorized"
|
|
4
|
+
CONFIRMATION_REQUIRED = "confirmation_required"
|
|
5
|
+
|
|
6
|
+
#: Rejection codes from §10.4. ``confirmation_required`` is a transient
|
|
7
|
+
#: success, not a rejection, and is handled separately.
|
|
8
|
+
ERROR_CODES = frozenset({
|
|
9
|
+
"invalid_request",
|
|
10
|
+
"unauthenticated",
|
|
11
|
+
"no_matching_grant",
|
|
12
|
+
"intent_not_allowed",
|
|
13
|
+
"grant_expired",
|
|
14
|
+
"grant_revoked",
|
|
15
|
+
"grant_paused",
|
|
16
|
+
"grant_suspended",
|
|
17
|
+
"limit_exceeded",
|
|
18
|
+
"request_expired",
|
|
19
|
+
"replay_detected",
|
|
20
|
+
"capability_unavailable",
|
|
21
|
+
"execution_failed",
|
|
22
|
+
"outcome_unknown",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CCPError(Exception):
|
|
27
|
+
"""An error carrying one of the §10.4 codes."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, code, message=""):
|
|
30
|
+
if code not in ERROR_CODES:
|
|
31
|
+
raise ValueError("unknown CCP error code: %r" % (code,))
|
|
32
|
+
self.code = code
|
|
33
|
+
self.message = message
|
|
34
|
+
detail = "%s: %s" % (code, message) if message else code
|
|
35
|
+
super().__init__(detail)
|
ccp/hosts.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Host-function registry and per-request privilege mediation (§26.4).
|
|
2
|
+
|
|
3
|
+
The runtime passes an :class:`HostAccess` proxy to an intent executor. The
|
|
4
|
+
proxy exposes only the host functions granted to the requester, so an
|
|
5
|
+
executor cannot reach capabilities outside the grant.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HostAccess:
|
|
10
|
+
def __init__(self, registry, granted):
|
|
11
|
+
self._registry = registry
|
|
12
|
+
self._granted = set(granted)
|
|
13
|
+
|
|
14
|
+
def call(self, name, *args, **kwargs):
|
|
15
|
+
if name not in self._granted:
|
|
16
|
+
raise PermissionError("host function not granted: %s" % (name,))
|
|
17
|
+
function = self._registry.host(name)
|
|
18
|
+
if function is None:
|
|
19
|
+
raise PermissionError("host function not installed: %s" % (name,))
|
|
20
|
+
return function(*args, **kwargs)
|
|
21
|
+
|
|
22
|
+
def has(self, name):
|
|
23
|
+
return name in self._granted and self._registry.host(name) is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HostRegistry:
|
|
27
|
+
"""Installed intents and their underlying host functions."""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self._hosts = {}
|
|
31
|
+
self._intents = {}
|
|
32
|
+
|
|
33
|
+
def register_host(self, name, function):
|
|
34
|
+
self._hosts[name] = function
|
|
35
|
+
|
|
36
|
+
def register_intent(self, name, function):
|
|
37
|
+
self._intents[name] = function
|
|
38
|
+
|
|
39
|
+
def host(self, name):
|
|
40
|
+
return self._hosts.get(name)
|
|
41
|
+
|
|
42
|
+
def has_host(self, name):
|
|
43
|
+
return name in self._hosts
|
|
44
|
+
|
|
45
|
+
def intent(self, name):
|
|
46
|
+
return self._intents.get(name)
|
|
47
|
+
|
|
48
|
+
def has_intent(self, name):
|
|
49
|
+
return name in self._intents
|
|
50
|
+
|
|
51
|
+
def access(self, granted):
|
|
52
|
+
return HostAccess(self, granted)
|