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/model.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Protocol data models: Manifest, Grant, Signal, Decision."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
|
|
5
|
+
from . import canonical, crypto
|
|
6
|
+
from .errors import AUTHORIZED, CONFIRMATION_REQUIRED, CCPError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Decision:
|
|
11
|
+
"""The outcome of submitting or confirming a signal."""
|
|
12
|
+
|
|
13
|
+
outcome: str
|
|
14
|
+
request_id: str | None = None
|
|
15
|
+
pending_id: str | None = None
|
|
16
|
+
result: object = None
|
|
17
|
+
error: str | None = None
|
|
18
|
+
detail: str | None = None
|
|
19
|
+
state: str | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def authorized(self):
|
|
23
|
+
return self.outcome == AUTHORIZED
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def needs_confirmation(self):
|
|
27
|
+
return self.outcome == CONFIRMATION_REQUIRED
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def rejected(self):
|
|
31
|
+
return self.outcome not in (AUTHORIZED, CONFIRMATION_REQUIRED)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Manifest:
|
|
35
|
+
"""A signed provider manifest (§8)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, document, data):
|
|
38
|
+
self.document = document
|
|
39
|
+
self.data = data
|
|
40
|
+
self._signing_input = canonical.signing_bytes(document)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def parse(cls, document):
|
|
44
|
+
data = canonical.parse_strict(document)
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise CCPError("invalid_request", "manifest must be a JSON object")
|
|
47
|
+
for name in ("ccp", "provider", "version", "intents", "signature"):
|
|
48
|
+
if name not in data:
|
|
49
|
+
raise CCPError("invalid_request", "manifest missing %r" % (name,))
|
|
50
|
+
if not isinstance(data["provider"], dict) or "id" not in data["provider"]:
|
|
51
|
+
raise CCPError("invalid_request", "manifest provider.id is required")
|
|
52
|
+
if not isinstance(data["intents"], dict):
|
|
53
|
+
raise CCPError("invalid_request", "manifest intents must be an object")
|
|
54
|
+
return cls(document, data)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def provider_id(self):
|
|
58
|
+
return self.data["provider"]["id"]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def provider_name(self):
|
|
62
|
+
return self.data["provider"].get("name")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def version(self):
|
|
66
|
+
return self.data["version"]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def intents(self):
|
|
70
|
+
return self.data["intents"]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def signature(self):
|
|
74
|
+
return self.data["signature"]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def signing_input(self):
|
|
78
|
+
return self._signing_input
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def digest(self):
|
|
82
|
+
"""`sha256:<hex>` over the canonical signing bytes (§26.1)."""
|
|
83
|
+
return canonical.digest_bytes(self._signing_input)
|
|
84
|
+
|
|
85
|
+
def intent_spec(self, name):
|
|
86
|
+
return self.intents.get(name)
|
|
87
|
+
|
|
88
|
+
def verify(self, expected_provider_id=None):
|
|
89
|
+
"""Verify the manifest signature; raise CCPError on failure."""
|
|
90
|
+
if expected_provider_id is not None and self.provider_id != expected_provider_id:
|
|
91
|
+
raise CCPError("unauthenticated", "provider id does not match")
|
|
92
|
+
key = crypto.VerifyingKey.from_key_id(self.provider_id)
|
|
93
|
+
if not key.verify(self._signing_input, self.signature):
|
|
94
|
+
raise CCPError("unauthenticated", "manifest signature is invalid")
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Grant:
|
|
100
|
+
"""The consumer's local authorization record (§26.1)."""
|
|
101
|
+
|
|
102
|
+
id: str
|
|
103
|
+
requester_key: str
|
|
104
|
+
contact_id: str
|
|
105
|
+
manifest_digest: str
|
|
106
|
+
manifest_version: int
|
|
107
|
+
intents: list = field(default_factory=list)
|
|
108
|
+
limits: dict = field(default_factory=dict)
|
|
109
|
+
created_at: str = ""
|
|
110
|
+
expires_at: str = ""
|
|
111
|
+
confirmation: dict = field(default_factory=dict)
|
|
112
|
+
capabilities: list = field(default_factory=list)
|
|
113
|
+
placement: list = field(default_factory=list)
|
|
114
|
+
egress: dict = field(default_factory=dict)
|
|
115
|
+
state: str = "active"
|
|
116
|
+
state_reason: str | None = None
|
|
117
|
+
|
|
118
|
+
def is_active(self, now):
|
|
119
|
+
return self.state == "active" and now < self.expires_at
|
|
120
|
+
|
|
121
|
+
def pause(self, reason=None):
|
|
122
|
+
if self.state in ("revoked", "expired"):
|
|
123
|
+
raise CCPError("invalid_request", "cannot pause a %s grant" % self.state)
|
|
124
|
+
self.state = "paused"
|
|
125
|
+
self.state_reason = reason
|
|
126
|
+
|
|
127
|
+
def suspend(self, reason=None):
|
|
128
|
+
if self.state in ("revoked", "expired"):
|
|
129
|
+
raise CCPError("invalid_request", "cannot suspend a %s grant" % self.state)
|
|
130
|
+
self.state = "suspended"
|
|
131
|
+
self.state_reason = reason
|
|
132
|
+
|
|
133
|
+
def resume(self):
|
|
134
|
+
if self.state not in ("paused", "suspended"):
|
|
135
|
+
raise CCPError("invalid_request", "cannot resume a %s grant" % self.state)
|
|
136
|
+
self.state = "active"
|
|
137
|
+
self.state_reason = None
|
|
138
|
+
|
|
139
|
+
def revoke(self, reason=None):
|
|
140
|
+
self.state = "revoked"
|
|
141
|
+
self.state_reason = reason
|
|
142
|
+
|
|
143
|
+
def to_dict(self):
|
|
144
|
+
return asdict(self)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_dict(cls, data):
|
|
148
|
+
return cls(**data)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Signal:
|
|
152
|
+
"""A signed request to invoke one intent (§10.1)."""
|
|
153
|
+
|
|
154
|
+
FIELDS = ("ccp", "request_id", "grant_digest", "intent", "params",
|
|
155
|
+
"issued_at", "expires_at", "signature")
|
|
156
|
+
|
|
157
|
+
def __init__(self, document, data):
|
|
158
|
+
self.document = document
|
|
159
|
+
self.data = data
|
|
160
|
+
self._signing_input = canonical.signing_bytes(document)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def parse(cls, document):
|
|
164
|
+
data = canonical.parse_strict(document)
|
|
165
|
+
if not isinstance(data, dict):
|
|
166
|
+
raise CCPError("invalid_request", "signal must be a JSON object")
|
|
167
|
+
for name in cls.FIELDS:
|
|
168
|
+
if name not in data:
|
|
169
|
+
raise CCPError("invalid_request", "signal missing %r" % (name,))
|
|
170
|
+
return cls(document, data)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def request_id(self):
|
|
174
|
+
return self.data["request_id"]
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def intent(self):
|
|
178
|
+
return self.data["intent"]
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def params(self):
|
|
182
|
+
return self.data["params"]
|
|
183
|
+
|
|
184
|
+
def verify(self, verifying_key):
|
|
185
|
+
if not verifying_key.verify(self._signing_input, self.data["signature"]):
|
|
186
|
+
raise CCPError("unauthenticated", "signal signature is invalid")
|
|
187
|
+
return True
|
ccp/requester.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Requester role: build and sign manifests and signals."""
|
|
2
|
+
|
|
3
|
+
from .errors import CCPError
|
|
4
|
+
from .signing import sign_document
|
|
5
|
+
|
|
6
|
+
DEFAULT_CCP = "0.4-draft"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_manifest(provider, version, intents, *, ccp=DEFAULT_CCP):
|
|
10
|
+
"""Build the unsigned manifest mapping (§8)."""
|
|
11
|
+
if not isinstance(provider, dict) or not provider.get("id"):
|
|
12
|
+
raise CCPError("invalid_request", "provider.id is required")
|
|
13
|
+
return {
|
|
14
|
+
"ccp": ccp,
|
|
15
|
+
"provider": dict(provider),
|
|
16
|
+
"version": version,
|
|
17
|
+
"intents": intents,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sign_manifest(provider, version, intents, signing_key, *,
|
|
22
|
+
ccp=DEFAULT_CCP):
|
|
23
|
+
"""Return a signed manifest in canonical wire form."""
|
|
24
|
+
return sign_document(
|
|
25
|
+
build_manifest(provider, version, intents, ccp=ccp), signing_key)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_signal(request_id, grant_digest, intent, params, issued_at,
|
|
29
|
+
expires_at, *, ccp=DEFAULT_CCP):
|
|
30
|
+
"""Build the unsigned signal mapping (§10.1)."""
|
|
31
|
+
return {
|
|
32
|
+
"ccp": ccp,
|
|
33
|
+
"request_id": request_id,
|
|
34
|
+
"grant_digest": grant_digest,
|
|
35
|
+
"intent": intent,
|
|
36
|
+
"params": params,
|
|
37
|
+
"issued_at": issued_at,
|
|
38
|
+
"expires_at": expires_at,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sign_signal(request_id, grant_digest, intent, params, issued_at,
|
|
43
|
+
expires_at, signing_key, *, ccp=DEFAULT_CCP):
|
|
44
|
+
"""Return a signed signal in canonical wire form."""
|
|
45
|
+
return sign_document(
|
|
46
|
+
build_signal(request_id, grant_digest, intent, params, issued_at,
|
|
47
|
+
expires_at, ccp=ccp),
|
|
48
|
+
signing_key,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Requester:
|
|
53
|
+
"""A signing identity that produces manifests and signals."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, signing_key):
|
|
56
|
+
self.signing_key = signing_key
|
|
57
|
+
self.key_id = signing_key.key_id
|
|
58
|
+
|
|
59
|
+
def manifest(self, provider, version, intents, *, ccp=DEFAULT_CCP):
|
|
60
|
+
return sign_manifest(provider, version, intents, self.signing_key,
|
|
61
|
+
ccp=ccp)
|
|
62
|
+
|
|
63
|
+
def signal(self, request_id, grant_digest, intent, params, issued_at,
|
|
64
|
+
expires_at, *, ccp=DEFAULT_CCP):
|
|
65
|
+
return sign_signal(request_id, grant_digest, intent, params,
|
|
66
|
+
issued_at, expires_at, self.signing_key, ccp=ccp)
|
ccp/runtime.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Reference CCP runtime: validation, confirmation, execution, audit.
|
|
2
|
+
|
|
3
|
+
Implements the §10.3 validation sequence and the §26.3 side-effect state
|
|
4
|
+
machine. Confirmation is two-phase: :meth:`Runtime.submit` returns
|
|
5
|
+
``confirmation_required`` with a ``pending_id``, and the trusted client
|
|
6
|
+
later calls :meth:`Runtime.confirm`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from . import canonical, crypto, schema
|
|
12
|
+
from .audit import AuditLog, utcnow
|
|
13
|
+
from .decision import decide
|
|
14
|
+
from .errors import AUTHORIZED, CONFIRMATION_REQUIRED, CCPError
|
|
15
|
+
from .hosts import HostRegistry
|
|
16
|
+
from .model import Decision, Signal
|
|
17
|
+
from .stores import (
|
|
18
|
+
GrantStore,
|
|
19
|
+
LimitTracker,
|
|
20
|
+
ManifestStore,
|
|
21
|
+
Pending,
|
|
22
|
+
PendingStore,
|
|
23
|
+
ReplayStore,
|
|
24
|
+
SchemaRegistry,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Runtime:
|
|
29
|
+
def __init__(self, *, grants=None, manifests=None, replay=None, limits=None,
|
|
30
|
+
pending=None, schemas=None, hosts=None, audit=None, clock=None,
|
|
31
|
+
supported_ccp="0.4-draft"):
|
|
32
|
+
self.grants = grants if grants is not None else GrantStore()
|
|
33
|
+
self.manifests = manifests if manifests is not None else ManifestStore()
|
|
34
|
+
self.schemas = schemas if schemas is not None else SchemaRegistry()
|
|
35
|
+
self.hosts = hosts if hosts is not None else HostRegistry()
|
|
36
|
+
self.audit = audit if audit is not None else AuditLog()
|
|
37
|
+
self.replay = replay if replay is not None else ReplayStore()
|
|
38
|
+
self.limits = limits if limits is not None else LimitTracker()
|
|
39
|
+
self.pending_store = pending if pending is not None else PendingStore()
|
|
40
|
+
self.clock = clock or utcnow
|
|
41
|
+
self.supported_ccp = supported_ccp
|
|
42
|
+
|
|
43
|
+
# -- public API ------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def submit(self, document, verifying_key=None):
|
|
46
|
+
"""Validate and act on one signed signal."""
|
|
47
|
+
now = self.clock()
|
|
48
|
+
try:
|
|
49
|
+
signal = Signal.parse(document)
|
|
50
|
+
except (canonical.CanonicalError, CCPError) as exc:
|
|
51
|
+
return self._reject("invalid_request", None, detail=str(exc))
|
|
52
|
+
request_id = signal.request_id
|
|
53
|
+
|
|
54
|
+
# authenticate the sender (transport provides the bound key)
|
|
55
|
+
if verifying_key is None:
|
|
56
|
+
return self._reject("unauthenticated", request_id,
|
|
57
|
+
detail="no authenticated signer")
|
|
58
|
+
try:
|
|
59
|
+
if not verifying_key.verify(signal._signing_input,
|
|
60
|
+
signal.data["signature"]):
|
|
61
|
+
return self._reject("unauthenticated", request_id,
|
|
62
|
+
detail="signature mismatch")
|
|
63
|
+
except (crypto.CryptoError, KeyError) as exc:
|
|
64
|
+
return self._reject("unauthenticated", request_id, detail=str(exc))
|
|
65
|
+
|
|
66
|
+
signer = verifying_key.key_id
|
|
67
|
+
|
|
68
|
+
# resolve the grant and its approved manifest
|
|
69
|
+
grant = self.grants.find(signer, signal.data["grant_digest"])
|
|
70
|
+
intent_spec = None
|
|
71
|
+
params_schema = None
|
|
72
|
+
if grant is not None:
|
|
73
|
+
manifest = self.manifests.get(grant.manifest_digest)
|
|
74
|
+
if manifest is not None:
|
|
75
|
+
intent_spec = manifest.intent_spec(signal.data["intent"])
|
|
76
|
+
if intent_spec is not None:
|
|
77
|
+
params_schema = self.schemas.get(
|
|
78
|
+
intent_spec.get("params_schema"))
|
|
79
|
+
counters = self.limits.counters(grant.id) if grant is not None else {}
|
|
80
|
+
|
|
81
|
+
decision = decide(
|
|
82
|
+
grant, signal.data, signer, now,
|
|
83
|
+
self.replay.snapshot(), counters,
|
|
84
|
+
intent_spec=intent_spec, params_schema=params_schema,
|
|
85
|
+
supported_ccp=self.supported_ccp,
|
|
86
|
+
)
|
|
87
|
+
if decision not in (AUTHORIZED, CONFIRMATION_REQUIRED):
|
|
88
|
+
return self._reject(decision, request_id, grant)
|
|
89
|
+
|
|
90
|
+
# accepted: apply replay and limit guards atomically
|
|
91
|
+
if not self.replay.mark_if_new(request_id):
|
|
92
|
+
return self._reject("replay_detected", request_id, grant)
|
|
93
|
+
if not self.limits.try_reserve(grant.id, grant.limits):
|
|
94
|
+
return self._reject("limit_exceeded", request_id, grant)
|
|
95
|
+
|
|
96
|
+
required = intent_spec.get("required_capabilities", [])
|
|
97
|
+
unavailable = [c for c in required if not self.hosts.has_host(c)]
|
|
98
|
+
if unavailable or not self.hosts.has_intent(signal.data["intent"]):
|
|
99
|
+
self.limits.release(grant.id, grant.limits)
|
|
100
|
+
return self._reject("capability_unavailable", request_id, grant,
|
|
101
|
+
detail="unavailable: %s"
|
|
102
|
+
% (unavailable or signal.data["intent"]))
|
|
103
|
+
|
|
104
|
+
if decision == CONFIRMATION_REQUIRED:
|
|
105
|
+
pending_id = uuid.uuid4().hex
|
|
106
|
+
self.pending_store.put(
|
|
107
|
+
Pending(pending_id, signal, grant, intent_spec))
|
|
108
|
+
self._audit("awaiting_confirmation", signal, grant)
|
|
109
|
+
return Decision(CONFIRMATION_REQUIRED, request_id=request_id,
|
|
110
|
+
pending_id=pending_id,
|
|
111
|
+
state="awaiting_confirmation")
|
|
112
|
+
|
|
113
|
+
return self._execute(signal, grant, intent_spec)
|
|
114
|
+
|
|
115
|
+
def confirm(self, pending_id, approved):
|
|
116
|
+
"""Resolve a pending confirmation."""
|
|
117
|
+
pending = self.pending_store.take(pending_id)
|
|
118
|
+
if pending is None:
|
|
119
|
+
return self._reject("invalid_request", None,
|
|
120
|
+
detail="unknown pending request")
|
|
121
|
+
|
|
122
|
+
if not approved:
|
|
123
|
+
self.limits.release(pending.grant.id, pending.grant.limits)
|
|
124
|
+
self._audit("cancelled", pending.signal, pending.grant)
|
|
125
|
+
return Decision("cancelled", request_id=pending.signal.request_id,
|
|
126
|
+
state="cancelled")
|
|
127
|
+
|
|
128
|
+
# The grant may have changed while the prompt was open (§9.4.3).
|
|
129
|
+
state_error = self._grant_state_error(pending.grant, self.clock())
|
|
130
|
+
if state_error:
|
|
131
|
+
self.limits.release(pending.grant.id, pending.grant.limits)
|
|
132
|
+
return self._reject(state_error, pending.signal.request_id,
|
|
133
|
+
pending.grant)
|
|
134
|
+
|
|
135
|
+
return self._execute(pending.signal, pending.grant, pending.intent_spec)
|
|
136
|
+
|
|
137
|
+
def pending(self, pending_id):
|
|
138
|
+
return self.pending_store.get(pending_id)
|
|
139
|
+
|
|
140
|
+
# -- internals -------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def _execute(self, signal, grant, intent_spec):
|
|
143
|
+
self._audit("executing", signal, grant)
|
|
144
|
+
executor = self.hosts.intent(signal.data["intent"])
|
|
145
|
+
access = self.hosts.access(grant.capabilities)
|
|
146
|
+
try:
|
|
147
|
+
result = executor(signal.data["params"], access)
|
|
148
|
+
except Exception as exc: # noqa: BLE001 - surfaced as a typed outcome
|
|
149
|
+
self.limits.release(grant.id, grant.limits)
|
|
150
|
+
self._audit("failed", signal, grant, detail=str(exc))
|
|
151
|
+
return Decision("execution_failed", request_id=signal.request_id,
|
|
152
|
+
error="execution_failed", detail=str(exc),
|
|
153
|
+
state="failed")
|
|
154
|
+
|
|
155
|
+
result_schema = self.schemas.get(intent_spec.get("result_schema"))
|
|
156
|
+
if result_schema is not None:
|
|
157
|
+
problems = schema.validate(result, result_schema)
|
|
158
|
+
if problems:
|
|
159
|
+
self.limits.release(grant.id, grant.limits)
|
|
160
|
+
self._audit("failed", signal, grant, detail="invalid result")
|
|
161
|
+
return Decision("execution_failed", request_id=signal.request_id,
|
|
162
|
+
error="execution_failed",
|
|
163
|
+
detail="invalid result: " + "; ".join(problems),
|
|
164
|
+
state="failed")
|
|
165
|
+
|
|
166
|
+
self.limits.commit(grant.id, grant.limits)
|
|
167
|
+
self._audit("completed", signal, grant)
|
|
168
|
+
return Decision(AUTHORIZED, request_id=signal.request_id,
|
|
169
|
+
result=result, state="completed")
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _grant_state_error(grant, now):
|
|
173
|
+
if grant.state == "revoked":
|
|
174
|
+
return "grant_revoked"
|
|
175
|
+
if grant.state == "paused":
|
|
176
|
+
return "grant_paused"
|
|
177
|
+
if grant.state == "suspended":
|
|
178
|
+
return "grant_suspended"
|
|
179
|
+
if grant.state == "expired" or now >= grant.expires_at:
|
|
180
|
+
return "grant_expired"
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def _reject(self, code, request_id, grant=None, detail=None):
|
|
184
|
+
self._audit("rejected", None, grant, request_id=request_id,
|
|
185
|
+
code=code, detail=detail)
|
|
186
|
+
return Decision(code, request_id=request_id, error=code, detail=detail)
|
|
187
|
+
|
|
188
|
+
def _audit(self, event, signal=None, grant=None, request_id=None,
|
|
189
|
+
code=None, detail=None):
|
|
190
|
+
record = {"event": event, "grant_id": grant.id if grant else None}
|
|
191
|
+
if signal is not None:
|
|
192
|
+
record["request_id"] = signal.request_id
|
|
193
|
+
record["intent"] = signal.data.get("intent")
|
|
194
|
+
if request_id is not None:
|
|
195
|
+
record["request_id"] = request_id
|
|
196
|
+
if code is not None:
|
|
197
|
+
record["code"] = code
|
|
198
|
+
if detail is not None:
|
|
199
|
+
record["detail"] = detail
|
|
200
|
+
self.audit.append(record)
|
ccp/schema.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""A minimal JSON Schema subset validator per CCP §25.3.
|
|
2
|
+
|
|
3
|
+
Supports the frozen subset used by the profile: ``type``, ``properties``,
|
|
4
|
+
``required``, ``additionalProperties``, ``items``, ``enum``, ``const``,
|
|
5
|
+
numeric bounds and ``multipleOf``, string length, array length and
|
|
6
|
+
uniqueness, and ``pattern``. Unknown keywords are ignored.
|
|
7
|
+
|
|
8
|
+
This is a reference validator, not a full JSON Schema implementation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SchemaError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate(value, schema):
|
|
19
|
+
"""Return a list of human-readable violations (empty means valid)."""
|
|
20
|
+
errors = []
|
|
21
|
+
_validate(value, schema, "$", errors)
|
|
22
|
+
return errors
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_valid(value, schema):
|
|
26
|
+
return not validate(value, schema)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _type_ok(value, name):
|
|
30
|
+
if name == "object":
|
|
31
|
+
return isinstance(value, dict)
|
|
32
|
+
if name == "array":
|
|
33
|
+
return isinstance(value, list)
|
|
34
|
+
if name == "string":
|
|
35
|
+
return isinstance(value, str)
|
|
36
|
+
if name == "integer":
|
|
37
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
38
|
+
if name == "number":
|
|
39
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
40
|
+
if name == "boolean":
|
|
41
|
+
return isinstance(value, bool)
|
|
42
|
+
if name == "null":
|
|
43
|
+
return value is None
|
|
44
|
+
raise SchemaError("unsupported type: %r" % (name,))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate(value, schema, path, errors):
|
|
48
|
+
if schema is False:
|
|
49
|
+
errors.append("%s: not allowed" % path)
|
|
50
|
+
return
|
|
51
|
+
if schema is True or schema is None:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
expected = schema.get("type")
|
|
55
|
+
if expected is not None:
|
|
56
|
+
names = expected if isinstance(expected, list) else [expected]
|
|
57
|
+
if not any(_type_ok(value, n) for n in names):
|
|
58
|
+
errors.append("%s: expected %s" % (path, "/".join(names)))
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
if "const" in schema and value != schema["const"]:
|
|
62
|
+
errors.append("%s: must equal %r" % (path, schema["const"]))
|
|
63
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
64
|
+
errors.append("%s: must be one of %r" % (path, schema["enum"]))
|
|
65
|
+
|
|
66
|
+
if isinstance(value, bool):
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if isinstance(value, (int, float)):
|
|
70
|
+
_validate_number(value, schema, path, errors)
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
_validate_string(value, schema, path, errors)
|
|
73
|
+
if isinstance(value, list):
|
|
74
|
+
_validate_array(value, schema, path, errors)
|
|
75
|
+
if isinstance(value, dict):
|
|
76
|
+
_validate_object(value, schema, path, errors)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_number(value, schema, path, errors):
|
|
80
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
81
|
+
errors.append("%s: below minimum" % path)
|
|
82
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
83
|
+
errors.append("%s: above maximum" % path)
|
|
84
|
+
if "exclusiveMinimum" in schema and value <= schema["exclusiveMinimum"]:
|
|
85
|
+
errors.append("%s: not above exclusiveMinimum" % path)
|
|
86
|
+
if "exclusiveMaximum" in schema and value >= schema["exclusiveMaximum"]:
|
|
87
|
+
errors.append("%s: not below exclusiveMaximum" % path)
|
|
88
|
+
if "multipleOf" in schema:
|
|
89
|
+
step = schema["multipleOf"]
|
|
90
|
+
if step:
|
|
91
|
+
if (isinstance(value, int) and not isinstance(value, bool)
|
|
92
|
+
and isinstance(step, int)):
|
|
93
|
+
if value % step != 0:
|
|
94
|
+
errors.append("%s: not a multiple of %r" % (path, step))
|
|
95
|
+
elif abs(value / step - round(value / step)) > 1e-9:
|
|
96
|
+
errors.append("%s: not a multiple of %r" % (path, step))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_string(value, schema, path, errors):
|
|
100
|
+
if "minLength" in schema and len(value) < schema["minLength"]:
|
|
101
|
+
errors.append("%s: shorter than minLength" % path)
|
|
102
|
+
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
|
103
|
+
errors.append("%s: longer than maxLength" % path)
|
|
104
|
+
if "pattern" in schema and re.search(schema["pattern"], value) is None:
|
|
105
|
+
errors.append("%s: does not match pattern" % path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_array(value, schema, path, errors):
|
|
109
|
+
if "minItems" in schema and len(value) < schema["minItems"]:
|
|
110
|
+
errors.append("%s: fewer than minItems" % path)
|
|
111
|
+
if "maxItems" in schema and len(value) > schema["maxItems"]:
|
|
112
|
+
errors.append("%s: more than maxItems" % path)
|
|
113
|
+
if schema.get("uniqueItems"):
|
|
114
|
+
if len({repr(v) for v in value}) != len(value):
|
|
115
|
+
errors.append("%s: items are not unique" % path)
|
|
116
|
+
items = schema.get("items")
|
|
117
|
+
if items is not None:
|
|
118
|
+
for index, item in enumerate(value):
|
|
119
|
+
_validate(item, items, "%s[%d]" % (path, index), errors)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validate_object(value, schema, path, errors):
|
|
123
|
+
properties = schema.get("properties", {})
|
|
124
|
+
for name in schema.get("required", []):
|
|
125
|
+
if name not in value:
|
|
126
|
+
errors.append("%s.%s: required" % (path, name))
|
|
127
|
+
additional = schema.get("additionalProperties", True)
|
|
128
|
+
for name, item in value.items():
|
|
129
|
+
if name in properties:
|
|
130
|
+
_validate(item, properties[name], "%s.%s" % (path, name), errors)
|
|
131
|
+
elif additional is False:
|
|
132
|
+
errors.append("%s.%s: additional property not allowed" % (path, name))
|
|
133
|
+
elif isinstance(additional, dict):
|
|
134
|
+
_validate(item, additional, "%s.%s" % (path, name), errors)
|
ccp/signing.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Helpers for signing CCP documents."""
|
|
2
|
+
|
|
3
|
+
from . import canonical
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sign_document(value, signing_key):
|
|
7
|
+
"""Return the canonical wire form of ``value`` signed by ``signing_key``.
|
|
8
|
+
|
|
9
|
+
The value is a mapping; any existing ``signature`` key is replaced.
|
|
10
|
+
"""
|
|
11
|
+
value = dict(value)
|
|
12
|
+
value.pop("signature", None)
|
|
13
|
+
value["signature"] = signing_key.sign(canonical.signing_bytes_of(value))
|
|
14
|
+
return canonical.serialize(value)
|
ccp/state_machine.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The side-effect state machine (§26.3)."""
|
|
2
|
+
|
|
3
|
+
#: (from, event) -> to. ``None`` is the start pseudo-state.
|
|
4
|
+
TRANSITIONS = {
|
|
5
|
+
(None, "arrive"): "received",
|
|
6
|
+
("received", "validate_ok"): "authorized",
|
|
7
|
+
("authorized", "confirmation_required"): "awaiting_confirmation",
|
|
8
|
+
("authorized", "no_confirmation"): "executing",
|
|
9
|
+
("awaiting_confirmation", "approved"): "executing",
|
|
10
|
+
("awaiting_confirmation", "denied"): "cancelled",
|
|
11
|
+
("executing", "succeeded"): "completed",
|
|
12
|
+
("executing", "failed"): "failed",
|
|
13
|
+
("executing", "cancelled"): "cancelled",
|
|
14
|
+
("executing", "outcome_lost"): "outcome_unknown",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
TERMINAL = frozenset({"completed", "failed", "cancelled", "outcome_unknown"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def next_state(state, event):
|
|
21
|
+
"""Return the state after ``event``; raise ValueError if undefined."""
|
|
22
|
+
if state == "start":
|
|
23
|
+
state = None
|
|
24
|
+
try:
|
|
25
|
+
return TRANSITIONS[(state, event)]
|
|
26
|
+
except KeyError:
|
|
27
|
+
raise ValueError("no transition from %r on %r" % (state, event))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reachable_states():
|
|
31
|
+
states = set(TRANSITIONS.values())
|
|
32
|
+
states.update(state for (state, _event) in TRANSITIONS if state is not None)
|
|
33
|
+
return states
|