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 ADDED
@@ -0,0 +1,97 @@
1
+ """CCP reference runtime and SDK.
2
+
3
+ Assembles the protocol primitives into a runtime that verifies manifests
4
+ and signals, enforces local grants, mediates host functions, supports
5
+ two-phase confirmation, and keeps a private audit log.
6
+ """
7
+
8
+ from .audit import AuditLog
9
+ from .agent import Agent, attenuates
10
+ from .canonical import CanonicalError, canonicalize, signed_wire, signing_bytes
11
+ from .consumer import (
12
+ PermissionCard,
13
+ permission_card,
14
+ verify_manifest,
15
+ verify_signal,
16
+ )
17
+ from .crypto import CryptoError, SigningKey, VerifyingKey
18
+ from .decision import decide
19
+ from .errors import AUTHORIZED, CONFIRMATION_REQUIRED, ERROR_CODES, CCPError
20
+ from .hosts import HostAccess, HostRegistry
21
+ from .model import Decision, Grant, Manifest, Signal
22
+ from .requester import (
23
+ Requester,
24
+ build_manifest,
25
+ build_signal,
26
+ sign_manifest,
27
+ sign_signal,
28
+ )
29
+ from .runtime import Runtime
30
+ from .signing import sign_document
31
+ from .stores import (
32
+ GrantStore,
33
+ LimitTracker,
34
+ ManifestStore,
35
+ Pending,
36
+ PendingStore,
37
+ ReplayStore,
38
+ SchemaRegistry,
39
+ )
40
+ from .stores_sqlite import (
41
+ SqliteAuditLog,
42
+ SqliteGrantStore,
43
+ SqliteLimitTracker,
44
+ SqliteManifestStore,
45
+ SqlitePendingStore,
46
+ SqliteReplayStore,
47
+ SqliteStores,
48
+ )
49
+
50
+ __all__ = [
51
+ "Agent",
52
+ "AuditLog",
53
+ "AUTHORIZED",
54
+ "CONFIRMATION_REQUIRED",
55
+ "CanonicalError",
56
+ "CCPError",
57
+ "CryptoError",
58
+ "Decision",
59
+ "ERROR_CODES",
60
+ "Grant",
61
+ "GrantStore",
62
+ "HostAccess",
63
+ "HostRegistry",
64
+ "LimitTracker",
65
+ "Manifest",
66
+ "ManifestStore",
67
+ "Pending",
68
+ "PendingStore",
69
+ "PermissionCard",
70
+ "ReplayStore",
71
+ "Requester",
72
+ "Runtime",
73
+ "SchemaRegistry",
74
+ "SigningKey",
75
+ "Signal",
76
+ "SqliteAuditLog",
77
+ "SqliteGrantStore",
78
+ "SqliteLimitTracker",
79
+ "SqliteManifestStore",
80
+ "SqlitePendingStore",
81
+ "SqliteReplayStore",
82
+ "SqliteStores",
83
+ "VerifyingKey",
84
+ "attenuates",
85
+ "build_manifest",
86
+ "build_signal",
87
+ "canonicalize",
88
+ "decide",
89
+ "permission_card",
90
+ "sign_document",
91
+ "sign_manifest",
92
+ "sign_signal",
93
+ "signed_wire",
94
+ "signing_bytes",
95
+ "verify_manifest",
96
+ "verify_signal",
97
+ ]
@@ -0,0 +1,5 @@
1
+ """Optional integrations. Import a submodule explicitly.
2
+
3
+ Importing :mod:`ccp` never imports FastAPI or MCP; these modules are opt-in
4
+ and their dependencies are optional extras.
5
+ """
@@ -0,0 +1,97 @@
1
+ """FastAPI integration for CCP providers.
2
+
3
+ The adapter only parses the HTTP envelope, resolves the sender key, and
4
+ delegates to :class:`ccp.runtime.Runtime`. It never decides authorization.
5
+ """
6
+
7
+ import inspect
8
+ from dataclasses import asdict
9
+
10
+ from fastapi import APIRouter, Request
11
+ from fastapi.responses import JSONResponse, Response
12
+
13
+ from ..errors import AUTHORIZED, CONFIRMATION_REQUIRED
14
+
15
+ _STATUS = {
16
+ AUTHORIZED: 200,
17
+ CONFIRMATION_REQUIRED: 200,
18
+ "invalid_request": 400,
19
+ "unauthenticated": 403,
20
+ "no_matching_grant": 403,
21
+ }
22
+
23
+
24
+ def _decision_body(decision):
25
+ return {
26
+ name: value
27
+ for name, value in asdict(decision).items()
28
+ if value is not None
29
+ }
30
+
31
+
32
+ def _error(status, code):
33
+ return JSONResponse({"outcome": code, "error": code},
34
+ status_code=status)
35
+
36
+
37
+ def signal_endpoint(runtime, key_resolver):
38
+ """Build an async FastAPI endpoint that accepts signed signals.
39
+
40
+ ``key_resolver`` receives the request and returns the sender's
41
+ :class:`ccp.crypto.VerifyingKey` (or an awaitable of one), or ``None``
42
+ when the sender is unknown.
43
+ """
44
+
45
+ async def endpoint(request: Request):
46
+ try:
47
+ body = await request.json()
48
+ except Exception:
49
+ return _error(400, "invalid_request")
50
+ if not isinstance(body, dict) or not isinstance(body.get("signal"), str):
51
+ return _error(400, "invalid_request")
52
+
53
+ try:
54
+ key = key_resolver(request)
55
+ if inspect.isawaitable(key):
56
+ key = await key
57
+ except Exception:
58
+ key = None
59
+ if key is None:
60
+ return _error(401, "unauthenticated")
61
+
62
+ try:
63
+ decision = runtime.submit(body["signal"], key)
64
+ except Exception:
65
+ return _error(500, "outcome_unknown")
66
+
67
+ return JSONResponse(
68
+ _decision_body(decision),
69
+ status_code=_STATUS.get(decision.outcome, 409),
70
+ )
71
+
72
+ return endpoint
73
+
74
+
75
+ def provider_router(runtime, key_resolver, *, path="/ccp"):
76
+ """An ``APIRouter`` with the provider's signal endpoint."""
77
+ router = APIRouter()
78
+ router.add_api_route(path, signal_endpoint(runtime, key_resolver),
79
+ methods=["POST"])
80
+ return router
81
+
82
+
83
+ def manifest_endpoint(manifest_wire):
84
+ """Build an endpoint that serves the signed manifest verbatim."""
85
+
86
+ async def endpoint(_request: Request):
87
+ return Response(content=manifest_wire, media_type="application/json")
88
+
89
+ return endpoint
90
+
91
+
92
+ def manifest_router(manifest_wire, *, path="/manifest"):
93
+ """An ``APIRouter`` with the provider's manifest endpoint."""
94
+ router = APIRouter()
95
+ router.add_api_route(path, manifest_endpoint(manifest_wire),
96
+ methods=["GET"])
97
+ return router
ccp/adapters/mcp.py ADDED
@@ -0,0 +1,208 @@
1
+ """Transport-neutral MCP bridge for CCP capabilities.
2
+
3
+ The bridge maps granted intents to MCP tools, signs a fresh signal per call,
4
+ and lets the runtime decide. It never approves a confirmation on the model's
5
+ behalf. Results are plain dataclasses so this module does not depend on an
6
+ MCP SDK; adapt them at the transport edge.
7
+ """
8
+
9
+ import inspect
10
+ import json
11
+ import uuid
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta, timezone
14
+
15
+ from ..consumer import PermissionCard, permission_card
16
+ from ..decision import confirmation_policy
17
+ from ..errors import AUTHORIZED, CONFIRMATION_REQUIRED, ERROR_CODES
18
+
19
+
20
+ @dataclass
21
+ class McpTool:
22
+ """One granted intent, shaped for an MCP tool listing."""
23
+
24
+ name: str
25
+ description: str
26
+ input_schema: dict
27
+ output_schema: dict | None = None
28
+
29
+
30
+ @dataclass
31
+ class McpToolResult:
32
+ """The transport-neutral form of an MCP tool result."""
33
+
34
+ content: list
35
+ structured_content: dict | None = None
36
+ is_error: bool = False
37
+
38
+
39
+ @dataclass
40
+ class ConfirmationRequest:
41
+ """The exact action a trusted host is asked to approve (§11.4)."""
42
+
43
+ pending_id: str
44
+ request_id: str
45
+ intent: str
46
+ params: dict
47
+ card: PermissionCard
48
+
49
+
50
+ class McpServer:
51
+ """One agent's view of one grant, exposed as MCP tools."""
52
+
53
+ def __init__(self, *, runtime, agent, grant, manifest=None, confirm=None,
54
+ request_ttl_seconds=60, now=None):
55
+ self._runtime = runtime
56
+ self._agent = agent
57
+ self._grant = grant
58
+ self._confirm = confirm
59
+ self._ttl = request_ttl_seconds
60
+ self._now = now or runtime.clock
61
+
62
+ manifest = (manifest if manifest is not None
63
+ else runtime.manifests.get(grant.manifest_digest))
64
+ if manifest is None:
65
+ raise ValueError("manifest not found for grant %s" % grant.id)
66
+ if grant.requester_key != agent.key_id:
67
+ raise ValueError("grant %s is not bound to this agent key"
68
+ % grant.id)
69
+ self._manifest = manifest
70
+ self.tools = self._build_tools()
71
+ self._by_name = {tool.name: tool for tool in self.tools}
72
+
73
+ # -- public API ------------------------------------------------------
74
+
75
+ async def call_tool(self, name, args):
76
+ """Run one tool call through the runtime; never raises."""
77
+ try:
78
+ tool = self._by_name.get(name)
79
+ if tool is None:
80
+ return _failure("intent_not_allowed")
81
+ if not isinstance(args, dict):
82
+ return _failure("invalid_request")
83
+
84
+ issued_at = self._now()
85
+ signal = self._agent.signal_for(
86
+ intent=name,
87
+ params=args,
88
+ grant=self._grant,
89
+ request_id=uuid.uuid4().hex,
90
+ issued_at=issued_at,
91
+ expires_at=_shift_seconds(issued_at, self._ttl),
92
+ )
93
+ decision = self._runtime.submit(signal,
94
+ self._agent.verifying_key)
95
+ return await self._settle(decision, tool, name, args)
96
+ except Exception:
97
+ return _failure("outcome_unknown")
98
+
99
+ # -- internals -------------------------------------------------------
100
+
101
+ def _build_tools(self):
102
+ tools = []
103
+ for intent in self._grant.intents:
104
+ definition = (self._manifest.intents or {}).get(intent)
105
+ if definition is None:
106
+ raise ValueError("intent %s is not declared in the manifest"
107
+ % intent)
108
+ input_schema = self._runtime.schemas.get(
109
+ definition.get("params_schema"))
110
+ if input_schema is None:
111
+ raise ValueError("params schema not registered: %s"
112
+ % definition.get("params_schema"))
113
+ tools.append(McpTool(
114
+ name=intent,
115
+ description=_describe(intent, definition, self._grant),
116
+ input_schema=input_schema,
117
+ output_schema=self._runtime.schemas.get(
118
+ definition.get("result_schema")),
119
+ ))
120
+ return tools
121
+
122
+ async def _settle(self, decision, tool, intent, params):
123
+ if decision.outcome == AUTHORIZED:
124
+ return _success(tool, decision.result)
125
+ if decision.outcome != CONFIRMATION_REQUIRED:
126
+ return _failure(decision.outcome,
127
+ request_id=decision.request_id,
128
+ detail=decision.detail)
129
+
130
+ pending_id = decision.pending_id
131
+ if pending_id is None:
132
+ return _failure("invalid_request")
133
+
134
+ if self._confirm is None:
135
+ return _failure(CONFIRMATION_REQUIRED,
136
+ request_id=decision.request_id,
137
+ pending_id=pending_id)
138
+
139
+ approved = False
140
+ try:
141
+ value = self._confirm(ConfirmationRequest(
142
+ pending_id=pending_id,
143
+ request_id=decision.request_id or "",
144
+ intent=intent,
145
+ params=params,
146
+ card=permission_card(self._manifest, intent),
147
+ ))
148
+ if inspect.isawaitable(value):
149
+ value = await value
150
+ approved = bool(value)
151
+ except Exception:
152
+ approved = False
153
+
154
+ resolved = self._runtime.confirm(pending_id, approved)
155
+ if resolved.outcome == AUTHORIZED:
156
+ return _success(tool, resolved.result)
157
+ if resolved.outcome == "cancelled":
158
+ return _failure("cancelled", request_id=resolved.request_id)
159
+ return _failure(resolved.outcome, request_id=resolved.request_id,
160
+ detail=resolved.detail)
161
+
162
+
163
+ def _describe(intent, definition, grant):
164
+ provider_text = definition.get("description")
165
+ if not isinstance(provider_text, str):
166
+ provider_text = ""
167
+ policy = confirmation_policy(grant, definition, intent)
168
+ confirmation = ("Each call may require explicit user confirmation. "
169
+ if policy == "per_action"
170
+ else "No per-call confirmation is required. ")
171
+ return (
172
+ 'User-granted CCP capability "%s". ' % intent
173
+ + "Provider description (untrusted data): %s. "
174
+ % json.dumps(provider_text, ensure_ascii=False)
175
+ + confirmation
176
+ + "The user's runtime enforces this capability."
177
+ )
178
+
179
+
180
+ def _success(tool, result):
181
+ content = [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]
182
+ if tool.output_schema is not None and isinstance(result, dict):
183
+ return McpToolResult(content=content, structured_content=result)
184
+ return McpToolResult(content=content)
185
+
186
+
187
+ def _failure(outcome, *, request_id=None, detail=None, pending_id=None):
188
+ body = {"outcome": outcome}
189
+ if outcome in ERROR_CODES:
190
+ body["error"] = outcome
191
+ if request_id is not None:
192
+ body["request_id"] = request_id
193
+ if detail is not None:
194
+ body["detail"] = detail
195
+ if pending_id is not None:
196
+ body["pending_id"] = pending_id
197
+ return McpToolResult(
198
+ content=[{"type": "text", "text": json.dumps(body)}],
199
+ is_error=True,
200
+ )
201
+
202
+
203
+ def _shift_seconds(timestamp, seconds):
204
+ normalized = (timestamp[:-1] + "+00:00"
205
+ if timestamp.endswith("Z") else timestamp)
206
+ moment = datetime.fromisoformat(normalized)
207
+ shifted = moment + timedelta(seconds=seconds)
208
+ return shifted.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
ccp/agent.py ADDED
@@ -0,0 +1,66 @@
1
+ """Agent role: signed tool calls and attenuation-only delegation."""
2
+
3
+ from .requester import DEFAULT_CCP, sign_signal
4
+
5
+
6
+ class Agent:
7
+ """A requester identity used by an AI agent for tool calls."""
8
+
9
+ def __init__(self, signing_key):
10
+ self.signing_key = signing_key
11
+ self.key_id = signing_key.key_id
12
+
13
+ @property
14
+ def verifying_key(self):
15
+ return self.signing_key.verifying_key()
16
+
17
+ def signal_for(self, *, intent, params, grant, request_id, issued_at,
18
+ expires_at, ccp=DEFAULT_CCP):
19
+ """Sign one tool call against the grant's manifest digest."""
20
+ return sign_signal(
21
+ request_id, grant.manifest_digest, intent, params,
22
+ issued_at, expires_at, self.signing_key, ccp=ccp,
23
+ )
24
+
25
+
26
+ def _is_subset(child, parent):
27
+ allowed = set(parent)
28
+ return all(item in allowed for item in child)
29
+
30
+
31
+ def _policy_rank(policy):
32
+ return 1 if policy == "per_action" else 0
33
+
34
+
35
+ def attenuates(parent, child):
36
+ """True when ``child`` is no broader than ``parent`` (§15.1)."""
37
+ if not _is_subset(child.intents, parent.intents):
38
+ return False
39
+ if not _is_subset(child.capabilities, parent.capabilities):
40
+ return False
41
+ if not _is_subset(child.placement, parent.placement):
42
+ return False
43
+ if child.expires_at > parent.expires_at:
44
+ return False
45
+
46
+ for name, maximum in (child.limits or {}).items():
47
+ if maximum > (parent.limits or {}).get(name, 0):
48
+ return False
49
+
50
+ parent_egress = parent.egress or {}
51
+ child_egress = child.egress or {}
52
+ if child_egress.get("network") is True and parent_egress.get("network") is not True:
53
+ return False
54
+ parent_fields = set(parent_egress.get("fields") or [])
55
+ child_fields = child_egress.get("fields") or []
56
+ if not set(child_fields).issubset(parent_fields):
57
+ return False
58
+
59
+ parent_confirmation = parent.confirmation or {}
60
+ child_confirmation = child.confirmation or {}
61
+ for intent in child.intents:
62
+ if _policy_rank(child_confirmation.get(intent)) < _policy_rank(
63
+ parent_confirmation.get(intent)):
64
+ return False
65
+
66
+ return True
ccp/audit.py ADDED
@@ -0,0 +1,39 @@
1
+ """A local, private audit log (§16, §26.1).
2
+
3
+ Events are kept in memory and, optionally, appended to a JSON-lines file.
4
+ The log is local by default and never uploaded.
5
+ """
6
+
7
+ import json
8
+ import threading
9
+ from datetime import datetime, timezone
10
+
11
+
12
+ def utcnow():
13
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
14
+
15
+
16
+ class AuditLog:
17
+ def __init__(self, path=None):
18
+ self._events = []
19
+ self._lock = threading.Lock()
20
+ self._path = path
21
+
22
+ def append(self, event):
23
+ record = dict(event)
24
+ record.setdefault("at", utcnow())
25
+ with self._lock:
26
+ self._events.append(record)
27
+ if self._path:
28
+ with open(self._path, "a", encoding="utf-8") as handle:
29
+ handle.write(json.dumps(record, ensure_ascii=False,
30
+ sort_keys=True) + "\n")
31
+ return record
32
+
33
+ def events(self):
34
+ with self._lock:
35
+ return list(self._events)
36
+
37
+ def __len__(self):
38
+ with self._lock:
39
+ return len(self._events)