agent-protocols 0.2.2__tar.gz → 0.3.1__tar.gz
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.
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/PKG-INFO +5 -2
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/README.md +3 -1
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/pyproject.toml +6 -2
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols/__init__.py +6 -0
- agent_protocols-0.3.1/src/agent_protocols/discourse.py +508 -0
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols/http_client.py +2 -1
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols/identity.py +26 -9
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols/profile.py +1 -0
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols.egg-info/PKG-INFO +5 -2
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols.egg-info/SOURCES.txt +5 -1
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols.egg-info/requires.txt +1 -0
- agent_protocols-0.3.1/tests/test_discourse.py +394 -0
- agent_protocols-0.3.1/tests/test_discourse_coverage.py +186 -0
- agent_protocols-0.3.1/tests/test_http_client.py +165 -0
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/tests/test_identity.py +24 -0
- agent_protocols-0.3.1/tests/test_identity_coverage.py +208 -0
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/tests/test_profile.py +11 -0
- agent_protocols-0.3.1/tests/test_profile_coverage.py +53 -0
- agent_protocols-0.2.2/src/agent_protocols/discourse.py +0 -319
- agent_protocols-0.2.2/tests/test_discourse.py +0 -154
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/setup.cfg +0 -0
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols/errors.py +0 -0
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
- {agent_protocols-0.2.2 → agent_protocols-0.3.1}/src/agent_protocols.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-protocols
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
5
5
|
Author: LDCLabs
|
|
6
6
|
License: MIT
|
|
@@ -8,6 +8,7 @@ Keywords: agent,protocol,ed25519,sdk
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Requires-Dist: cryptography>=42
|
|
11
|
+
Requires-Dist: jsonschema<5,>=4.17
|
|
11
12
|
Requires-Dist: rfc8785<0.2,>=0.1.4
|
|
12
13
|
Provides-Extra: http
|
|
13
14
|
Requires-Dist: requests<3,>=2.31; extra == "http"
|
|
@@ -20,7 +21,7 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
|
|
|
20
21
|
|
|
21
22
|
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
22
23
|
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
23
|
-
- `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
|
|
24
|
+
- `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
|
|
24
25
|
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
25
26
|
|
|
26
27
|
## Example
|
|
@@ -39,3 +40,5 @@ event = profile_update_event(
|
|
|
39
40
|
envelope = signer.sign_event(event)
|
|
40
41
|
profile = materialize_profile(envelope)
|
|
41
42
|
```
|
|
43
|
+
|
|
44
|
+
`username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
|
|
@@ -6,7 +6,7 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
|
|
|
6
6
|
|
|
7
7
|
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
8
8
|
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
9
|
-
- `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
|
|
9
|
+
- `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
|
|
10
10
|
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
11
11
|
|
|
12
12
|
## Example
|
|
@@ -25,3 +25,5 @@ event = profile_update_event(
|
|
|
25
25
|
envelope = signer.sign_event(event)
|
|
26
26
|
profile = materialize_profile(envelope)
|
|
27
27
|
```
|
|
28
|
+
|
|
29
|
+
`username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agent-protocols"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
license = { text = "MIT" }
|
|
8
8
|
authors = [{ name = "LDCLabs" }]
|
|
9
9
|
keywords = ["agent", "protocol", "ed25519", "sdk"]
|
|
10
|
-
dependencies = [
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cryptography>=42",
|
|
12
|
+
"jsonschema>=4.17,<5",
|
|
13
|
+
"rfc8785>=0.1.4,<0.2",
|
|
14
|
+
]
|
|
11
15
|
|
|
12
16
|
[project.optional-dependencies]
|
|
13
17
|
http = ["requests>=2.31,<3"]
|
|
@@ -11,14 +11,17 @@ from .identity import (
|
|
|
11
11
|
create_event,
|
|
12
12
|
create_request_jwt_claims,
|
|
13
13
|
event_hash,
|
|
14
|
+
event_hash_bytes,
|
|
14
15
|
public_key_bytes,
|
|
15
16
|
sign_event,
|
|
17
|
+
sign_event_hash,
|
|
16
18
|
unix_ms,
|
|
17
19
|
unix_secs,
|
|
18
20
|
validate_agent_id,
|
|
19
21
|
validate_nonce,
|
|
20
22
|
verify_envelope,
|
|
21
23
|
verify_event_hash,
|
|
24
|
+
verify_event_hash_signature,
|
|
22
25
|
verify_live_envelope,
|
|
23
26
|
verify_request_jwt,
|
|
24
27
|
verify_signature,
|
|
@@ -42,10 +45,12 @@ __all__ = [
|
|
|
42
45
|
"create_event",
|
|
43
46
|
"create_request_jwt_claims",
|
|
44
47
|
"event_hash",
|
|
48
|
+
"event_hash_bytes",
|
|
45
49
|
"materialize_profile",
|
|
46
50
|
"profile_update_event",
|
|
47
51
|
"public_key_bytes",
|
|
48
52
|
"sign_event",
|
|
53
|
+
"sign_event_hash",
|
|
49
54
|
"unix_ms",
|
|
50
55
|
"unix_secs",
|
|
51
56
|
"validate_agent_id",
|
|
@@ -53,6 +58,7 @@ __all__ = [
|
|
|
53
58
|
"validate_profile_update",
|
|
54
59
|
"verify_envelope",
|
|
55
60
|
"verify_event_hash",
|
|
61
|
+
"verify_event_hash_signature",
|
|
56
62
|
"verify_live_envelope",
|
|
57
63
|
"verify_request_jwt",
|
|
58
64
|
"verify_signature",
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Agent Discourse Protocol 1.0: kernel types, the room type system, and
|
|
2
|
+
verification helpers.
|
|
3
|
+
|
|
4
|
+
The protocol defines nine built-in event types. Every other event type is
|
|
5
|
+
declared per room as a schema-validated type definition, either inline or
|
|
6
|
+
imported from a type pack. Hosts validate structure and permissions; they
|
|
7
|
+
never need to understand application semantics.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any, Iterable, Literal, TypedDict
|
|
16
|
+
|
|
17
|
+
import rfc8785
|
|
18
|
+
from jsonschema.validators import Draft202012Validator
|
|
19
|
+
|
|
20
|
+
from .errors import AgentProtocolError
|
|
21
|
+
from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
|
|
22
|
+
|
|
23
|
+
DISCOURSE_PROTOCOL = "agent-discourse/1.0"
|
|
24
|
+
|
|
25
|
+
# The nine built-in event types. All other types are room-defined.
|
|
26
|
+
ROOM_CREATE = "room.create"
|
|
27
|
+
ROOM_JOIN = "room.join"
|
|
28
|
+
ROOM_JOIN_REVIEW = "room.join.review"
|
|
29
|
+
ROOM_LEAVE = "room.leave"
|
|
30
|
+
ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
|
|
31
|
+
ROOM_CLOSE = "room.close"
|
|
32
|
+
ROOM_CANCEL = "room.cancel"
|
|
33
|
+
TYPE_DEFINE = "type.define"
|
|
34
|
+
MESSAGE_CREATE = "message.create"
|
|
35
|
+
|
|
36
|
+
BUILTIN_EVENT_TYPES = {
|
|
37
|
+
ROOM_CREATE,
|
|
38
|
+
ROOM_JOIN,
|
|
39
|
+
ROOM_JOIN_REVIEW,
|
|
40
|
+
ROOM_LEAVE,
|
|
41
|
+
ROOM_MEMBER_ROLE_UPDATE,
|
|
42
|
+
ROOM_CLOSE,
|
|
43
|
+
ROOM_CANCEL,
|
|
44
|
+
TYPE_DEFINE,
|
|
45
|
+
MESSAGE_CREATE,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Custom event types must not use these prefixes.
|
|
49
|
+
RESERVED_TYPE_PREFIXES = ("room.", "type.")
|
|
50
|
+
|
|
51
|
+
# Registered type packs defined by the specification in `1.0.packs.json`.
|
|
52
|
+
PACK_REACTIONS = "adp:reactions/1.0"
|
|
53
|
+
PACK_DELIBERATION = "adp:deliberation/1.0"
|
|
54
|
+
PACK_CURATION = "adp:curation/1.0"
|
|
55
|
+
PACK_MODERATION = "adp:moderation/1.0"
|
|
56
|
+
PACK_REALTIME = "adp:realtime/1.0"
|
|
57
|
+
|
|
58
|
+
REGISTERED_PACK_IDS = (
|
|
59
|
+
PACK_REACTIONS,
|
|
60
|
+
PACK_DELIBERATION,
|
|
61
|
+
PACK_CURATION,
|
|
62
|
+
PACK_MODERATION,
|
|
63
|
+
PACK_REALTIME,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
RoomState = Literal["scheduled", "active", "ended", "cancelled"]
|
|
67
|
+
Role = Literal["moderator", "speaker", "observer"]
|
|
68
|
+
TypeKind = Literal["message", "signal", "control"]
|
|
69
|
+
TypeStatus = Literal["active", "deprecated", "disabled"]
|
|
70
|
+
JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
|
|
71
|
+
|
|
72
|
+
ROLES = ("moderator", "speaker", "observer")
|
|
73
|
+
TYPE_KINDS = ("message", "signal", "control")
|
|
74
|
+
TYPE_STATUSES = ("active", "deprecated", "disabled")
|
|
75
|
+
|
|
76
|
+
_TYPE_SEGMENT = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
77
|
+
_REGISTERED_PACK_ID = re.compile(r"^adp:[a-z0-9-]+/[0-9]+\.[0-9]+$")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PermissionContext(TypedDict, total=False):
|
|
81
|
+
"""Permission inputs for one actor in one room."""
|
|
82
|
+
|
|
83
|
+
role: Role
|
|
84
|
+
is_creator: bool
|
|
85
|
+
join_request_approved: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
|
|
89
|
+
return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def type_define_event(
|
|
93
|
+
actor: AgentId, created_at: int, nonce: int, room_id: str, declaration: dict[str, Any]
|
|
94
|
+
) -> Event:
|
|
95
|
+
return with_room_id(
|
|
96
|
+
create_event(DISCOURSE_PROTOCOL, TYPE_DEFINE, actor, created_at, nonce, declaration),
|
|
97
|
+
room_id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
|
|
102
|
+
return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_builtin_event_type(event_type: str) -> bool:
|
|
106
|
+
return event_type in BUILTIN_EVENT_TYPES
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def event_requires_room_id(event_type: str) -> bool:
|
|
110
|
+
return event_type != ROOM_CREATE
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def validate_discourse_envelope(envelope: Envelope) -> None:
|
|
114
|
+
verify_envelope(envelope)
|
|
115
|
+
event = envelope["event"]
|
|
116
|
+
protocol = event["protocol"]
|
|
117
|
+
if protocol != DISCOURSE_PROTOCOL:
|
|
118
|
+
raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
|
|
119
|
+
if event["type"] == ROOM_CREATE:
|
|
120
|
+
if "room_id" in event:
|
|
121
|
+
raise AgentProtocolError("invalid_event", "room.create must not include room_id")
|
|
122
|
+
elif "room_id" not in event:
|
|
123
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
|
|
127
|
+
event = envelope["event"]
|
|
128
|
+
actual = event.get("room_id")
|
|
129
|
+
if event["type"] == ROOM_CREATE:
|
|
130
|
+
if actual is not None:
|
|
131
|
+
raise AgentProtocolError("invalid_event", "room.create must not include room_id")
|
|
132
|
+
return
|
|
133
|
+
if actual is None:
|
|
134
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
135
|
+
if actual != path_room_id:
|
|
136
|
+
raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_custom_event_type_name(name: str) -> None:
|
|
140
|
+
"""Checks the shape of a custom event type name: lowercase dot-separated,
|
|
141
|
+
at least two segments, not built-in, not under a reserved prefix."""
|
|
142
|
+
segments = name.split(".")
|
|
143
|
+
if len(segments) < 2 or not all(_TYPE_SEGMENT.match(segment) for segment in segments):
|
|
144
|
+
raise AgentProtocolError("invalid_event", f"invalid event type name: {name}")
|
|
145
|
+
if is_builtin_event_type(name):
|
|
146
|
+
raise AgentProtocolError("invalid_event", f"{name} is a built-in event type")
|
|
147
|
+
if name.startswith(RESERVED_TYPE_PREFIXES):
|
|
148
|
+
raise AgentProtocolError("invalid_event", f"{name} uses a reserved type prefix")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_pack_import(declaration: dict[str, Any]) -> bool:
|
|
152
|
+
return "use" in declaration or "pack" in declaration or "digest" in declaration
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_type_def(definition: dict[str, Any]) -> None:
|
|
156
|
+
validate_custom_event_type_name(str(definition.get("type", "")))
|
|
157
|
+
if definition.get("kind") not in TYPE_KINDS:
|
|
158
|
+
raise AgentProtocolError("invalid_event", f"invalid type kind: {definition.get('kind')}")
|
|
159
|
+
if not str(definition.get("title", "")).strip():
|
|
160
|
+
raise AgentProtocolError("invalid_event", "type definition title must not be empty")
|
|
161
|
+
schema = definition.get("schema")
|
|
162
|
+
if not isinstance(schema, dict):
|
|
163
|
+
raise AgentProtocolError("invalid_event", "type definition schema must be a JSON Schema object")
|
|
164
|
+
_compile_schema(schema)
|
|
165
|
+
roles = definition.get("roles")
|
|
166
|
+
if roles is not None:
|
|
167
|
+
if not isinstance(roles, list) or not roles or any(role not in ROLES for role in roles):
|
|
168
|
+
raise AgentProtocolError("invalid_event", "type definition roles must be a non-empty role list")
|
|
169
|
+
status = definition.get("status")
|
|
170
|
+
if status is not None and status not in TYPE_STATUSES:
|
|
171
|
+
raise AgentProtocolError("invalid_event", f"invalid type status: {status}")
|
|
172
|
+
for hint in ("rate_hint", "max_payload_hint"):
|
|
173
|
+
value = definition.get(hint)
|
|
174
|
+
if value is not None and (not isinstance(value, int) or value < 1):
|
|
175
|
+
raise AgentProtocolError("invalid_event", "type definition hints must be positive integers")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def validate_pack_import(declaration: dict[str, Any]) -> None:
|
|
179
|
+
has_use = "use" in declaration
|
|
180
|
+
has_external = "pack" in declaration and "digest" in declaration
|
|
181
|
+
if has_use:
|
|
182
|
+
if "pack" in declaration or "digest" in declaration:
|
|
183
|
+
raise AgentProtocolError("invalid_event", "pack import requires either use, or pack with digest")
|
|
184
|
+
if not _REGISTERED_PACK_ID.match(str(declaration["use"])):
|
|
185
|
+
raise AgentProtocolError("invalid_event", f"invalid registered pack id: {declaration['use']}")
|
|
186
|
+
elif has_external:
|
|
187
|
+
if not str(declaration["digest"]).strip():
|
|
188
|
+
raise AgentProtocolError("invalid_event", "external pack digest must not be empty")
|
|
189
|
+
else:
|
|
190
|
+
raise AgentProtocolError("invalid_event", "pack import requires either use, or pack with digest")
|
|
191
|
+
types = declaration.get("types")
|
|
192
|
+
if types is not None and (not isinstance(types, list) or not types):
|
|
193
|
+
raise AgentProtocolError("invalid_event", "pack import types subset must not be empty")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def validate_type_declaration(declaration: dict[str, Any]) -> None:
|
|
197
|
+
if not isinstance(declaration, dict):
|
|
198
|
+
raise AgentProtocolError("invalid_event", "type declaration must be an object")
|
|
199
|
+
if is_pack_import(declaration):
|
|
200
|
+
validate_pack_import(declaration)
|
|
201
|
+
elif "type" in declaration:
|
|
202
|
+
validate_type_def(declaration)
|
|
203
|
+
else:
|
|
204
|
+
raise AgentProtocolError(
|
|
205
|
+
"invalid_event", "type declaration must be an inline definition or a pack import"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pack_map(document: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
210
|
+
"""Indexes the packs of a document by pack id for registry materialization."""
|
|
211
|
+
return {pack["id"]: pack for pack in document.get("packs", [])}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TypeRegistry:
|
|
215
|
+
"""The effective set of type definitions active in a room."""
|
|
216
|
+
|
|
217
|
+
def __init__(self) -> None:
|
|
218
|
+
self._types: dict[str, dict[str, Any]] = {}
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def from_declarations(
|
|
222
|
+
cls,
|
|
223
|
+
declarations: Iterable[dict[str, Any]],
|
|
224
|
+
packs: dict[str, dict[str, Any]] | None = None,
|
|
225
|
+
) -> "TypeRegistry":
|
|
226
|
+
"""Materializes a registry from declarations, resolving pack imports
|
|
227
|
+
from `packs`, keyed by registered pack id or external pack URI."""
|
|
228
|
+
registry = cls()
|
|
229
|
+
for declaration in declarations:
|
|
230
|
+
registry.apply(declaration, packs)
|
|
231
|
+
return registry
|
|
232
|
+
|
|
233
|
+
def apply(self, declaration: dict[str, Any], packs: dict[str, dict[str, Any]] | None = None) -> None:
|
|
234
|
+
"""Applies one declaration: an inline definition or a pack import.
|
|
235
|
+
Redefining an existing type replaces it; the latest definition wins."""
|
|
236
|
+
if is_pack_import(declaration):
|
|
237
|
+
self._import(declaration, packs or {})
|
|
238
|
+
elif "type" in declaration:
|
|
239
|
+
self.define(declaration)
|
|
240
|
+
else:
|
|
241
|
+
raise AgentProtocolError(
|
|
242
|
+
"invalid_event", "type declaration must be an inline definition or a pack import"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def define(self, definition: dict[str, Any]) -> None:
|
|
246
|
+
validate_type_def(definition)
|
|
247
|
+
self._types[definition["type"]] = definition
|
|
248
|
+
|
|
249
|
+
def _import(self, declaration: dict[str, Any], packs: dict[str, dict[str, Any]]) -> None:
|
|
250
|
+
validate_pack_import(declaration)
|
|
251
|
+
reference = declaration.get("use") or declaration.get("pack")
|
|
252
|
+
pack = packs.get(reference)
|
|
253
|
+
if pack is None:
|
|
254
|
+
raise AgentProtocolError("pack_unavailable", f"pack not available: {reference}")
|
|
255
|
+
available = {definition["type"] for definition in pack.get("types", [])}
|
|
256
|
+
subset = declaration.get("types")
|
|
257
|
+
if subset is not None:
|
|
258
|
+
for name in subset:
|
|
259
|
+
if name not in available:
|
|
260
|
+
raise AgentProtocolError("pack_unavailable", f"type {name} is not in pack {reference}")
|
|
261
|
+
overrides = declaration.get("overrides") or {}
|
|
262
|
+
for name in overrides:
|
|
263
|
+
imported = name in subset if subset is not None else name in available
|
|
264
|
+
if not imported:
|
|
265
|
+
raise AgentProtocolError(
|
|
266
|
+
"invalid_event", f"override target {name} is not imported from pack {reference}"
|
|
267
|
+
)
|
|
268
|
+
for definition in pack.get("types", []):
|
|
269
|
+
if subset is not None and definition["type"] not in subset:
|
|
270
|
+
continue
|
|
271
|
+
merged = dict(definition)
|
|
272
|
+
merged.update(overrides.get(definition["type"], {}))
|
|
273
|
+
self.define(merged)
|
|
274
|
+
|
|
275
|
+
def get(self, event_type: str) -> dict[str, Any] | None:
|
|
276
|
+
return self._types.get(event_type)
|
|
277
|
+
|
|
278
|
+
def __contains__(self, event_type: str) -> bool:
|
|
279
|
+
return event_type in self._types
|
|
280
|
+
|
|
281
|
+
def __len__(self) -> int:
|
|
282
|
+
return len(self._types)
|
|
283
|
+
|
|
284
|
+
def definitions(self) -> list[dict[str, Any]]:
|
|
285
|
+
return list(self._types.values())
|
|
286
|
+
|
|
287
|
+
def validate_payload(self, event_type: str, payload: Any) -> None:
|
|
288
|
+
"""Validates a custom event payload against the type's schema and status."""
|
|
289
|
+
definition = self._types.get(event_type)
|
|
290
|
+
if definition is None:
|
|
291
|
+
raise AgentProtocolError("type_not_defined", f"event type is not defined in the room: {event_type}")
|
|
292
|
+
if definition.get("status", "active") == "disabled":
|
|
293
|
+
raise AgentProtocolError("type_disabled", f"event type is disabled in this room: {event_type}")
|
|
294
|
+
validator = _compile_schema(definition["schema"])
|
|
295
|
+
errors = sorted(validator.iter_errors(payload), key=lambda error: list(error.absolute_path))
|
|
296
|
+
if errors:
|
|
297
|
+
detail = "; ".join(error.message for error in errors[:3])
|
|
298
|
+
raise AgentProtocolError("payload_schema_violation", f"{event_type}: {detail}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def validate_event_against_registry(event_type: str, payload: Any, registry: TypeRegistry) -> None:
|
|
302
|
+
"""Validates an event payload: built-in payloads are accepted as-is (use
|
|
303
|
+
the typed validators for them); custom payloads must satisfy the registry."""
|
|
304
|
+
if is_builtin_event_type(event_type):
|
|
305
|
+
return
|
|
306
|
+
registry.validate_payload(event_type, payload)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _compile_schema(schema: dict[str, Any]) -> Draft202012Validator:
|
|
310
|
+
try:
|
|
311
|
+
Draft202012Validator.check_schema(schema)
|
|
312
|
+
except Exception as error: # jsonschema.SchemaError
|
|
313
|
+
raise AgentProtocolError("invalid_event", f"invalid type schema: {error}") from error
|
|
314
|
+
return Draft202012Validator(schema)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def verify_pack_digest(data: bytes, digest: str) -> None:
|
|
318
|
+
"""Verifies a `<algorithm>:<base64url-digest>` content digest over raw
|
|
319
|
+
bytes. Supports `sha256` and `sha3-256`."""
|
|
320
|
+
algorithm, separator, expected = digest.partition(":")
|
|
321
|
+
if not separator:
|
|
322
|
+
raise AgentProtocolError("pack_unavailable", f"invalid digest format: {digest}")
|
|
323
|
+
if algorithm == "sha256":
|
|
324
|
+
raw = hashlib.sha256(data).digest()
|
|
325
|
+
elif algorithm == "sha3-256":
|
|
326
|
+
raw = hashlib.sha3_256(data).digest()
|
|
327
|
+
else:
|
|
328
|
+
raise AgentProtocolError("pack_unavailable", f"unsupported digest algorithm: {algorithm}")
|
|
329
|
+
actual = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
330
|
+
if actual != expected:
|
|
331
|
+
raise AgentProtocolError("pack_unavailable", "pack digest mismatch")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def validate_room_create_payload(payload: dict[str, Any]) -> None:
|
|
335
|
+
if not str(payload.get("topic", "")).strip():
|
|
336
|
+
raise AgentProtocolError("invalid_event", "room topic must not be empty")
|
|
337
|
+
if payload.get("start_time", 0) >= payload.get("end_time", 0):
|
|
338
|
+
raise AgentProtocolError("invalid_event", "start_time must be before end_time")
|
|
339
|
+
policy = payload.get("policy") or {}
|
|
340
|
+
max_speakers = policy.get("max_speakers")
|
|
341
|
+
if max_speakers is not None and (not isinstance(max_speakers, int) or max_speakers < 1):
|
|
342
|
+
raise AgentProtocolError("invalid_event", "max_speakers must be a positive integer")
|
|
343
|
+
for declaration in payload.get("types", []):
|
|
344
|
+
validate_type_declaration(declaration)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def validate_message_create_payload(payload: dict[str, Any]) -> None:
|
|
348
|
+
if not str(payload.get("content_type", "")).strip():
|
|
349
|
+
raise AgentProtocolError("invalid_event", "content_type must not be empty")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def server_record_hash_payload(
|
|
353
|
+
room_id: str,
|
|
354
|
+
seq: int,
|
|
355
|
+
pre_hash: str | None,
|
|
356
|
+
envelope_hash: str,
|
|
357
|
+
received_at: int,
|
|
358
|
+
) -> dict[str, Any]:
|
|
359
|
+
return {
|
|
360
|
+
"room_id": room_id,
|
|
361
|
+
"seq": seq,
|
|
362
|
+
"pre_hash": pre_hash,
|
|
363
|
+
"envelope_hash": envelope_hash,
|
|
364
|
+
"received_at": received_at,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def server_record_hash(
|
|
369
|
+
room_id: str,
|
|
370
|
+
seq: int,
|
|
371
|
+
pre_hash: str | None,
|
|
372
|
+
envelope_hash: str,
|
|
373
|
+
received_at: int,
|
|
374
|
+
) -> str:
|
|
375
|
+
return _hash_canonical_json(server_record_hash_payload(room_id, seq, pre_hash, envelope_hash, received_at))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def build_server_record(
|
|
379
|
+
room_id: str,
|
|
380
|
+
seq: int,
|
|
381
|
+
pre_hash: str | None,
|
|
382
|
+
received_at: int,
|
|
383
|
+
envelope: Envelope,
|
|
384
|
+
) -> dict[str, Any]:
|
|
385
|
+
return {
|
|
386
|
+
"room_id": room_id,
|
|
387
|
+
"seq": seq,
|
|
388
|
+
"pre_hash": pre_hash,
|
|
389
|
+
"hash": server_record_hash(room_id, seq, pre_hash, envelope["hash"], received_at),
|
|
390
|
+
"received_at": received_at,
|
|
391
|
+
"envelope": envelope,
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def verify_server_record(record: dict[str, Any]) -> None:
|
|
396
|
+
expected = server_record_hash(
|
|
397
|
+
record["room_id"],
|
|
398
|
+
record["seq"],
|
|
399
|
+
record.get("pre_hash"),
|
|
400
|
+
record["envelope"]["hash"],
|
|
401
|
+
record["received_at"],
|
|
402
|
+
)
|
|
403
|
+
if record["hash"] != expected:
|
|
404
|
+
raise AgentProtocolError("invalid_record_hash", f"invalid server record hash: expected {expected}, got {record['hash']}")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def verify_server_record_chain(records: list[dict[str, Any]]) -> None:
|
|
408
|
+
previous: dict[str, Any] | None = None
|
|
409
|
+
for record in records:
|
|
410
|
+
verify_server_record(record)
|
|
411
|
+
if previous is None:
|
|
412
|
+
if record["seq"] != 1:
|
|
413
|
+
raise AgentProtocolError("invalid_record_chain", "first seq must be 1")
|
|
414
|
+
if record.get("pre_hash") is not None:
|
|
415
|
+
raise AgentProtocolError("invalid_record_chain", "first pre_hash must be null")
|
|
416
|
+
else:
|
|
417
|
+
if record["seq"] != previous["seq"] + 1:
|
|
418
|
+
raise AgentProtocolError("invalid_record_chain", "seq must increase by 1")
|
|
419
|
+
if record.get("pre_hash") != previous["hash"]:
|
|
420
|
+
raise AgentProtocolError("invalid_record_chain", "pre_hash mismatch")
|
|
421
|
+
previous = record
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def archive_events_digest(records: list[dict[str, Any]]) -> str:
|
|
425
|
+
return _hash_canonical_json(records)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def default_kind_roles(kind: str) -> tuple[str, ...]:
|
|
429
|
+
"""Default sender roles for each kind. The creator passes every role check."""
|
|
430
|
+
if kind == "message":
|
|
431
|
+
return ("moderator", "speaker")
|
|
432
|
+
if kind == "signal":
|
|
433
|
+
return ("moderator", "speaker", "observer")
|
|
434
|
+
if kind == "control":
|
|
435
|
+
return ("moderator",)
|
|
436
|
+
raise AgentProtocolError("invalid_event", f"invalid type kind: {kind}")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def can_submit_event(
|
|
440
|
+
event_type: str,
|
|
441
|
+
context: PermissionContext,
|
|
442
|
+
registry: TypeRegistry | None = None,
|
|
443
|
+
) -> bool:
|
|
444
|
+
"""Role check for one event type, using kind defaults and per-type role
|
|
445
|
+
overrides from the room's type registry. State checks are separate."""
|
|
446
|
+
is_creator = bool(context.get("is_creator"))
|
|
447
|
+
role = context.get("role")
|
|
448
|
+
if event_type == ROOM_CREATE:
|
|
449
|
+
return True
|
|
450
|
+
if event_type == ROOM_JOIN:
|
|
451
|
+
return bool(context.get("join_request_approved"))
|
|
452
|
+
if event_type == ROOM_LEAVE:
|
|
453
|
+
return is_creator or role is not None
|
|
454
|
+
if event_type in {ROOM_JOIN_REVIEW, ROOM_MEMBER_ROLE_UPDATE, ROOM_CLOSE, ROOM_CANCEL, TYPE_DEFINE}:
|
|
455
|
+
return is_creator or role == "moderator"
|
|
456
|
+
if event_type == MESSAGE_CREATE:
|
|
457
|
+
return is_creator or role in ("moderator", "speaker")
|
|
458
|
+
|
|
459
|
+
definition = registry.get(event_type) if registry is not None else None
|
|
460
|
+
if definition is None or definition.get("status", "active") == "disabled":
|
|
461
|
+
return False
|
|
462
|
+
if is_creator:
|
|
463
|
+
return True
|
|
464
|
+
if role is None:
|
|
465
|
+
return False
|
|
466
|
+
roles = definition.get("roles") or default_kind_roles(definition["kind"])
|
|
467
|
+
return role in roles
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def can_write_in_state(event_type: str, state: RoomState) -> bool:
|
|
471
|
+
if state == "scheduled":
|
|
472
|
+
return event_type in {
|
|
473
|
+
ROOM_JOIN,
|
|
474
|
+
ROOM_JOIN_REVIEW,
|
|
475
|
+
ROOM_MEMBER_ROLE_UPDATE,
|
|
476
|
+
ROOM_LEAVE,
|
|
477
|
+
TYPE_DEFINE,
|
|
478
|
+
ROOM_CANCEL,
|
|
479
|
+
}
|
|
480
|
+
if state == "active":
|
|
481
|
+
return event_type not in {ROOM_CREATE, ROOM_CANCEL}
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def can_accept_room_write(
|
|
486
|
+
event_type: str,
|
|
487
|
+
state: RoomState,
|
|
488
|
+
context: PermissionContext,
|
|
489
|
+
registry: TypeRegistry | None = None,
|
|
490
|
+
) -> bool:
|
|
491
|
+
return can_submit_event(event_type, context, registry) and can_write_in_state(event_type, state)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def validate_room_write(
|
|
495
|
+
event_type: str,
|
|
496
|
+
state: RoomState,
|
|
497
|
+
context: PermissionContext,
|
|
498
|
+
registry: TypeRegistry | None = None,
|
|
499
|
+
) -> None:
|
|
500
|
+
if not can_accept_room_write(event_type, state, context, registry):
|
|
501
|
+
raise AgentProtocolError("permission_denied", "actor lacks permission or state is not writable")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _hash_canonical_json(value: Any) -> str:
|
|
505
|
+
canonical = rfc8785.dumps(value)
|
|
506
|
+
data = canonical if isinstance(canonical, bytes) else canonical.encode()
|
|
507
|
+
digest = hashlib.sha3_256(data).digest()
|
|
508
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|