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/__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
|
+
]
|
ccp/adapters/__init__.py
ADDED
ccp/adapters/fastapi.py
ADDED
|
@@ -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)
|