agent-protocols 0.1.0__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.1.0/PKG-INFO +41 -0
- agent_protocols-0.1.0/README.md +26 -0
- agent_protocols-0.1.0/pyproject.toml +20 -0
- agent_protocols-0.1.0/setup.cfg +4 -0
- agent_protocols-0.1.0/src/agent_protocols/__init__.py +61 -0
- agent_protocols-0.1.0/src/agent_protocols/discourse.py +190 -0
- agent_protocols-0.1.0/src/agent_protocols/errors.py +4 -0
- agent_protocols-0.1.0/src/agent_protocols/http_client.py +75 -0
- agent_protocols-0.1.0/src/agent_protocols/identity.py +274 -0
- agent_protocols-0.1.0/src/agent_protocols/profile.py +45 -0
- agent_protocols-0.1.0/src/agent_protocols.egg-info/PKG-INFO +41 -0
- agent_protocols-0.1.0/src/agent_protocols.egg-info/SOURCES.txt +16 -0
- agent_protocols-0.1.0/src/agent_protocols.egg-info/dependency_links.txt +1 -0
- agent_protocols-0.1.0/src/agent_protocols.egg-info/requires.txt +6 -0
- agent_protocols-0.1.0/src/agent_protocols.egg-info/top_level.txt +1 -0
- agent_protocols-0.1.0/tests/test_discourse.py +52 -0
- agent_protocols-0.1.0/tests/test_identity.py +86 -0
- agent_protocols-0.1.0/tests/test_profile.py +30 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-protocols
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
5
|
+
Author: LDCLabs
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: agent,protocol,ed25519,sdk
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: base58<3,>=2.1
|
|
11
|
+
Requires-Dist: cryptography>=42
|
|
12
|
+
Requires-Dist: rfc8785<0.2,>=0.1.4
|
|
13
|
+
Provides-Extra: http
|
|
14
|
+
Requires-Dist: requests<3,>=2.31; extra == "http"
|
|
15
|
+
|
|
16
|
+
# agent-protocols Python SDK
|
|
17
|
+
|
|
18
|
+
Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
|
|
19
|
+
|
|
20
|
+
## Modules
|
|
21
|
+
|
|
22
|
+
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
23
|
+
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
24
|
+
- `agent_protocols.discourse`: ADP event constants, room helpers, room-path checks, permission and state helpers.
|
|
25
|
+
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
26
|
+
|
|
27
|
+
## Example
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
|
|
31
|
+
|
|
32
|
+
signer = AgentSigner.generate()
|
|
33
|
+
event = profile_update_event(
|
|
34
|
+
signer.agent_id(),
|
|
35
|
+
unix_time_millis(),
|
|
36
|
+
"n_01J8Z6",
|
|
37
|
+
{"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
38
|
+
)
|
|
39
|
+
envelope = signer.sign_event(event)
|
|
40
|
+
profile = materialize_profile(envelope)
|
|
41
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# agent-protocols Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
|
|
7
|
+
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
8
|
+
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
9
|
+
- `agent_protocols.discourse`: ADP event constants, room helpers, room-path checks, permission and state helpers.
|
|
10
|
+
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
11
|
+
|
|
12
|
+
## Example
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
|
|
16
|
+
|
|
17
|
+
signer = AgentSigner.generate()
|
|
18
|
+
event = profile_update_event(
|
|
19
|
+
signer.agent_id(),
|
|
20
|
+
unix_time_millis(),
|
|
21
|
+
"n_01J8Z6",
|
|
22
|
+
{"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
23
|
+
)
|
|
24
|
+
envelope = signer.sign_event(event)
|
|
25
|
+
profile = materialize_profile(envelope)
|
|
26
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent-protocols"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "LDCLabs" }]
|
|
9
|
+
keywords = ["agent", "protocol", "ed25519", "sdk"]
|
|
10
|
+
dependencies = ["base58>=2.1,<3", "cryptography>=42", "rfc8785>=0.1.4,<0.2"]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
http = ["requests>=2.31,<3"]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["setuptools>=68"]
|
|
17
|
+
build-backend = "setuptools.build_meta"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from .errors import AgentProtocolError
|
|
2
|
+
from .identity import (
|
|
3
|
+
AGENT_ID_PREFIX,
|
|
4
|
+
EVENT_ID_PREFIX,
|
|
5
|
+
AgentSigner,
|
|
6
|
+
MemoryNonceStore,
|
|
7
|
+
RequestBinding,
|
|
8
|
+
agent_id_from_public_key,
|
|
9
|
+
canonical_event_bytes,
|
|
10
|
+
create_event,
|
|
11
|
+
create_request_jwt_claims,
|
|
12
|
+
event_id,
|
|
13
|
+
public_key_bytes,
|
|
14
|
+
random_nonce,
|
|
15
|
+
sign_event,
|
|
16
|
+
unix_time_millis,
|
|
17
|
+
unix_time_secs,
|
|
18
|
+
validate_agent_id,
|
|
19
|
+
verify_envelope,
|
|
20
|
+
verify_event_id,
|
|
21
|
+
verify_live_envelope,
|
|
22
|
+
verify_request_jwt,
|
|
23
|
+
verify_request_jwt_live,
|
|
24
|
+
verify_signature,
|
|
25
|
+
verify_timestamp,
|
|
26
|
+
with_room_id,
|
|
27
|
+
)
|
|
28
|
+
from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, profile_update_event, validate_profile_update
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AGENT_ID_PREFIX",
|
|
32
|
+
"EVENT_ID_PREFIX",
|
|
33
|
+
"PROFILE_PROTOCOL",
|
|
34
|
+
"PROFILE_UPDATE",
|
|
35
|
+
"AgentProtocolError",
|
|
36
|
+
"AgentSigner",
|
|
37
|
+
"MemoryNonceStore",
|
|
38
|
+
"RequestBinding",
|
|
39
|
+
"agent_id_from_public_key",
|
|
40
|
+
"canonical_event_bytes",
|
|
41
|
+
"create_event",
|
|
42
|
+
"create_request_jwt_claims",
|
|
43
|
+
"event_id",
|
|
44
|
+
"materialize_profile",
|
|
45
|
+
"profile_update_event",
|
|
46
|
+
"public_key_bytes",
|
|
47
|
+
"random_nonce",
|
|
48
|
+
"sign_event",
|
|
49
|
+
"unix_time_millis",
|
|
50
|
+
"unix_time_secs",
|
|
51
|
+
"validate_agent_id",
|
|
52
|
+
"validate_profile_update",
|
|
53
|
+
"verify_envelope",
|
|
54
|
+
"verify_event_id",
|
|
55
|
+
"verify_live_envelope",
|
|
56
|
+
"verify_request_jwt",
|
|
57
|
+
"verify_request_jwt_live",
|
|
58
|
+
"verify_signature",
|
|
59
|
+
"verify_timestamp",
|
|
60
|
+
"with_room_id",
|
|
61
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, TypedDict
|
|
4
|
+
|
|
5
|
+
from .errors import AgentProtocolError
|
|
6
|
+
from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
|
|
7
|
+
|
|
8
|
+
DISCOURSE_PROTOCOL = "agent-discourse/1.0"
|
|
9
|
+
LEGACY_DISCOURSE_PROTOCOL = "adp/1.0"
|
|
10
|
+
|
|
11
|
+
ROOM_CREATE = "room.create"
|
|
12
|
+
ROOM_JOIN = "room.join"
|
|
13
|
+
ROOM_LEAVE = "room.leave"
|
|
14
|
+
ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
|
|
15
|
+
ROOM_INVITE = "room.invite"
|
|
16
|
+
ROOM_INVITE_REVOKE = "room.invite.revoke"
|
|
17
|
+
ROOM_CLOSE = "room.close"
|
|
18
|
+
ROOM_CANCEL = "room.cancel"
|
|
19
|
+
MESSAGE_TEXT = "message.text"
|
|
20
|
+
MESSAGE_MARKDOWN = "message.markdown"
|
|
21
|
+
MESSAGE_DATA = "message.data"
|
|
22
|
+
REACTION_CREATE = "reaction.create"
|
|
23
|
+
PROPOSAL_CREATE = "proposal.create"
|
|
24
|
+
POLL_CREATE = "poll.create"
|
|
25
|
+
POLL_VOTE = "poll.vote"
|
|
26
|
+
RESOLUTION_CREATE = "resolution.create"
|
|
27
|
+
SOURCE_ADD = "source.add"
|
|
28
|
+
TURN_UPDATE = "turn.update"
|
|
29
|
+
QUESTION_GENERATE = "question.generate"
|
|
30
|
+
DISCOURSE_STEER = "discourse.steer"
|
|
31
|
+
MINDMAP_UPDATE = "mindmap.update"
|
|
32
|
+
REPORT_GENERATE = "report.generate"
|
|
33
|
+
SESSION_AUTH = "session.auth"
|
|
34
|
+
|
|
35
|
+
KNOWN_EVENT_TYPES = {
|
|
36
|
+
ROOM_CREATE,
|
|
37
|
+
ROOM_JOIN,
|
|
38
|
+
ROOM_LEAVE,
|
|
39
|
+
ROOM_MEMBER_ROLE_UPDATE,
|
|
40
|
+
ROOM_INVITE,
|
|
41
|
+
ROOM_INVITE_REVOKE,
|
|
42
|
+
ROOM_CLOSE,
|
|
43
|
+
ROOM_CANCEL,
|
|
44
|
+
MESSAGE_TEXT,
|
|
45
|
+
MESSAGE_MARKDOWN,
|
|
46
|
+
MESSAGE_DATA,
|
|
47
|
+
REACTION_CREATE,
|
|
48
|
+
PROPOSAL_CREATE,
|
|
49
|
+
POLL_CREATE,
|
|
50
|
+
POLL_VOTE,
|
|
51
|
+
RESOLUTION_CREATE,
|
|
52
|
+
SOURCE_ADD,
|
|
53
|
+
TURN_UPDATE,
|
|
54
|
+
QUESTION_GENERATE,
|
|
55
|
+
DISCOURSE_STEER,
|
|
56
|
+
MINDMAP_UPDATE,
|
|
57
|
+
REPORT_GENERATE,
|
|
58
|
+
SESSION_AUTH,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
RoomState = Literal["scheduled", "active", "ended", "cancelled"]
|
|
62
|
+
Role = Literal["moderator", "expert", "participant", "observer"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PermissionContext(TypedDict, total=False):
|
|
66
|
+
role: Role
|
|
67
|
+
is_creator: bool
|
|
68
|
+
moderator_authorized: bool
|
|
69
|
+
expert_policy_allowed: bool
|
|
70
|
+
participant_policy_allowed: bool
|
|
71
|
+
observer_steering_allowed: bool
|
|
72
|
+
observer_poll_vote_allowed: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def room_create_event(actor: AgentId, created_at: int, nonce: str, payload: dict[str, Any]) -> Event:
|
|
76
|
+
return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: str, room_id: str, payload: Any) -> Event:
|
|
80
|
+
return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_discourse_envelope(envelope: Envelope, accept_legacy_protocol: bool = False) -> None:
|
|
84
|
+
verify_envelope(envelope)
|
|
85
|
+
event = envelope["event"]
|
|
86
|
+
protocol = event["protocol"]
|
|
87
|
+
if protocol != DISCOURSE_PROTOCOL and not (accept_legacy_protocol and protocol == LEGACY_DISCOURSE_PROTOCOL):
|
|
88
|
+
raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
|
|
89
|
+
if event_requires_room_id(event["type"]) and "room_id" not in event:
|
|
90
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
|
|
94
|
+
actual = envelope["event"].get("room_id")
|
|
95
|
+
if actual is None:
|
|
96
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
97
|
+
if actual != path_room_id:
|
|
98
|
+
raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def event_requires_room_id(event_type: str) -> bool:
|
|
102
|
+
return event_type != ROOM_CREATE
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def can_submit_event(event_type: str, context: PermissionContext) -> bool:
|
|
106
|
+
if event_type in {ROOM_CREATE, ROOM_JOIN}:
|
|
107
|
+
return True
|
|
108
|
+
if context.get("is_creator"):
|
|
109
|
+
return event_type in KNOWN_EVENT_TYPES
|
|
110
|
+
|
|
111
|
+
role = context.get("role")
|
|
112
|
+
if role == "moderator":
|
|
113
|
+
return _moderator_can_submit(event_type, context.get("moderator_authorized", False))
|
|
114
|
+
if role == "expert":
|
|
115
|
+
return _speaker_can_submit(event_type, context.get("expert_policy_allowed", False))
|
|
116
|
+
if role == "participant":
|
|
117
|
+
return _speaker_can_submit(event_type, context.get("participant_policy_allowed", False))
|
|
118
|
+
if role == "observer":
|
|
119
|
+
return _observer_can_submit(event_type, context)
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def can_write_in_state(event_type: str, state: RoomState, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
124
|
+
if state == "scheduled":
|
|
125
|
+
return event_type in {ROOM_JOIN, ROOM_INVITE, ROOM_INVITE_REVOKE, ROOM_CANCEL}
|
|
126
|
+
if state == "active":
|
|
127
|
+
return event_type not in {ROOM_CREATE, ROOM_CANCEL}
|
|
128
|
+
if state == "ended":
|
|
129
|
+
return post_end_reaction_allowed and event_type == REACTION_CREATE
|
|
130
|
+
if state == "cancelled":
|
|
131
|
+
return False
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def can_accept_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
136
|
+
return can_submit_event(event_type, context) and can_write_in_state(event_type, state, post_end_reaction_allowed=post_end_reaction_allowed)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> None:
|
|
140
|
+
if not can_accept_room_write(event_type, state, context, post_end_reaction_allowed=post_end_reaction_allowed):
|
|
141
|
+
raise AgentProtocolError("permission_denied", "actor lacks permission or state is not writable")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
|
|
145
|
+
allowed = {
|
|
146
|
+
ROOM_INVITE,
|
|
147
|
+
ROOM_INVITE_REVOKE,
|
|
148
|
+
ROOM_CLOSE,
|
|
149
|
+
MESSAGE_TEXT,
|
|
150
|
+
MESSAGE_MARKDOWN,
|
|
151
|
+
MESSAGE_DATA,
|
|
152
|
+
SOURCE_ADD,
|
|
153
|
+
TURN_UPDATE,
|
|
154
|
+
QUESTION_GENERATE,
|
|
155
|
+
DISCOURSE_STEER,
|
|
156
|
+
MINDMAP_UPDATE,
|
|
157
|
+
REPORT_GENERATE,
|
|
158
|
+
PROPOSAL_CREATE,
|
|
159
|
+
POLL_CREATE,
|
|
160
|
+
POLL_VOTE,
|
|
161
|
+
RESOLUTION_CREATE,
|
|
162
|
+
REACTION_CREATE,
|
|
163
|
+
ROOM_LEAVE,
|
|
164
|
+
}
|
|
165
|
+
return event_type in allowed or (moderator_authorized and event_type in {ROOM_MEMBER_ROLE_UPDATE, ROOM_CANCEL})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
|
|
169
|
+
allowed = {
|
|
170
|
+
MESSAGE_TEXT,
|
|
171
|
+
MESSAGE_MARKDOWN,
|
|
172
|
+
MESSAGE_DATA,
|
|
173
|
+
SOURCE_ADD,
|
|
174
|
+
DISCOURSE_STEER,
|
|
175
|
+
PROPOSAL_CREATE,
|
|
176
|
+
POLL_CREATE,
|
|
177
|
+
POLL_VOTE,
|
|
178
|
+
REACTION_CREATE,
|
|
179
|
+
ROOM_LEAVE,
|
|
180
|
+
}
|
|
181
|
+
policy_events = {QUESTION_GENERATE, MINDMAP_UPDATE, REPORT_GENERATE, RESOLUTION_CREATE}
|
|
182
|
+
return event_type in allowed or (policy_allowed and event_type in policy_events)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
|
|
186
|
+
return (
|
|
187
|
+
event_type in {REACTION_CREATE, ROOM_LEAVE}
|
|
188
|
+
or (context.get("observer_steering_allowed", False) and event_type == DISCOURSE_STEER)
|
|
189
|
+
or (context.get("observer_poll_vote_allowed", False) and event_type == POLL_VOTE)
|
|
190
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import requests
|
|
7
|
+
except ImportError: # pragma: no cover
|
|
8
|
+
requests = None # type: ignore[assignment]
|
|
9
|
+
|
|
10
|
+
from .identity import AgentId, Envelope
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProfileClient:
|
|
14
|
+
def __init__(self, base_url: str, session: Any | None = None):
|
|
15
|
+
self.base_url = base_url.rstrip("/")
|
|
16
|
+
self.session = session or _requests_session()
|
|
17
|
+
|
|
18
|
+
def get_profile(self, agent_id: AgentId) -> dict[str, Any]:
|
|
19
|
+
return self._get(f"/v1/profiles/{agent_id}")
|
|
20
|
+
|
|
21
|
+
def submit_profile_update(self, envelope: Envelope) -> dict[str, Any]:
|
|
22
|
+
return self._post("/v1/profiles", envelope)
|
|
23
|
+
|
|
24
|
+
def _get(self, path: str) -> dict[str, Any]:
|
|
25
|
+
response = self.session.get(self.base_url + path)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
return response.json()
|
|
28
|
+
|
|
29
|
+
def _post(self, path: str, body: Any) -> dict[str, Any]:
|
|
30
|
+
response = self.session.post(self.base_url + path, json=body)
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
return response.json()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DiscourseClient:
|
|
36
|
+
def __init__(self, base_url: str, session: Any | None = None):
|
|
37
|
+
self.base_url = base_url.rstrip("/")
|
|
38
|
+
self.session = session or _requests_session()
|
|
39
|
+
|
|
40
|
+
def protocol(self) -> dict[str, Any]:
|
|
41
|
+
return self._get("/v1/protocol")
|
|
42
|
+
|
|
43
|
+
def create_room(self, envelope: Envelope) -> dict[str, Any]:
|
|
44
|
+
return self._post("/v1/rooms", envelope)
|
|
45
|
+
|
|
46
|
+
def join_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
47
|
+
return self._post(f"/v1/rooms/{room_id}/join", envelope)
|
|
48
|
+
|
|
49
|
+
def leave_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
50
|
+
return self._post(f"/v1/rooms/{room_id}/leave", envelope)
|
|
51
|
+
|
|
52
|
+
def submit_event(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
53
|
+
return self._post(f"/v1/rooms/{room_id}/events", envelope)
|
|
54
|
+
|
|
55
|
+
def events(self, room_id: str) -> list[dict[str, Any]]:
|
|
56
|
+
return self._get(f"/v1/rooms/{room_id}/events")
|
|
57
|
+
|
|
58
|
+
def archive(self, room_id: str) -> dict[str, Any]:
|
|
59
|
+
return self._get(f"/v1/rooms/{room_id}/archive")
|
|
60
|
+
|
|
61
|
+
def _get(self, path: str) -> Any:
|
|
62
|
+
response = self.session.get(self.base_url + path)
|
|
63
|
+
response.raise_for_status()
|
|
64
|
+
return response.json()
|
|
65
|
+
|
|
66
|
+
def _post(self, path: str, body: Any) -> Any:
|
|
67
|
+
response = self.session.post(self.base_url + path, json=body)
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
return response.json()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _requests_session() -> Any:
|
|
73
|
+
if requests is None:
|
|
74
|
+
raise RuntimeError("Install agent-protocols[http] to use HTTP clients")
|
|
75
|
+
return requests.Session()
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, MutableMapping, Protocol
|
|
10
|
+
|
|
11
|
+
import base58
|
|
12
|
+
import rfc8785
|
|
13
|
+
from cryptography.exceptions import InvalidSignature
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
15
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
16
|
+
|
|
17
|
+
from .errors import AgentProtocolError
|
|
18
|
+
|
|
19
|
+
AGENT_ID_PREFIX = "did:agent:"
|
|
20
|
+
EVENT_ID_PREFIX = "evt_"
|
|
21
|
+
DEFAULT_LIVE_WRITE_WINDOW_MS = 300_000
|
|
22
|
+
DEFAULT_REQUEST_JWT_TTL_SECS = 300
|
|
23
|
+
_REQUEST_AUTH_REPLAY_SCOPE = "agent-identity/request-auth"
|
|
24
|
+
|
|
25
|
+
Event = dict[str, Any]
|
|
26
|
+
Envelope = dict[str, Any]
|
|
27
|
+
AgentId = str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def agent_id_from_public_key(public_key: bytes) -> AgentId:
|
|
31
|
+
if len(public_key) != 32:
|
|
32
|
+
raise AgentProtocolError("invalid_public_key", f"public key must be 32 bytes, got {len(public_key)}")
|
|
33
|
+
return f"{AGENT_ID_PREFIX}{_base58btc_encode(public_key)}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def public_key_bytes(agent_id: AgentId) -> bytes:
|
|
37
|
+
if not agent_id.startswith(AGENT_ID_PREFIX):
|
|
38
|
+
raise AgentProtocolError("invalid_agent_id", "agent id must start with did:agent:")
|
|
39
|
+
data = _base58btc_decode(agent_id[len(AGENT_ID_PREFIX):])
|
|
40
|
+
if len(data) != 32:
|
|
41
|
+
raise AgentProtocolError("invalid_public_key", f"agent id public key must be 32 bytes, got {len(data)}")
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_agent_id(agent_id: AgentId) -> AgentId:
|
|
46
|
+
public_key_bytes(agent_id)
|
|
47
|
+
return agent_id
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RequestBinding:
|
|
52
|
+
audience: str
|
|
53
|
+
method: str
|
|
54
|
+
host: str
|
|
55
|
+
path: str
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def create(cls, audience: str, method: str, host: str, path: str) -> "RequestBinding":
|
|
59
|
+
return cls(audience=audience, method=method.upper(), host=host, path=path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AgentSigner:
|
|
63
|
+
def __init__(self, private_key: Ed25519PrivateKey):
|
|
64
|
+
self._private_key = private_key
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def generate(cls) -> "AgentSigner":
|
|
68
|
+
return cls(Ed25519PrivateKey.generate())
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_seed(cls, seed: bytes) -> "AgentSigner":
|
|
72
|
+
if len(seed) != 32:
|
|
73
|
+
raise AgentProtocolError("invalid_private_key", f"seed must be 32 bytes, got {len(seed)}")
|
|
74
|
+
return cls(Ed25519PrivateKey.from_private_bytes(seed))
|
|
75
|
+
|
|
76
|
+
def public_key(self) -> bytes:
|
|
77
|
+
return self._private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
78
|
+
|
|
79
|
+
def agent_id(self) -> AgentId:
|
|
80
|
+
return agent_id_from_public_key(self.public_key())
|
|
81
|
+
|
|
82
|
+
def sign_event(self, event: Event) -> Envelope:
|
|
83
|
+
return {
|
|
84
|
+
"event_id": event_id(event),
|
|
85
|
+
"event": event,
|
|
86
|
+
"signature": sign_event(self._private_key, event),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def sign_request_jwt(self, claims: dict[str, Any]) -> str:
|
|
90
|
+
agent_id = self.agent_id()
|
|
91
|
+
if claims.get("iss") != agent_id or claims.get("sub") != agent_id:
|
|
92
|
+
raise AgentProtocolError("invalid_jwt_claim", "iss and sub must match the signing agent id")
|
|
93
|
+
header = {"alg": "EdDSA", "typ": "JWT", "kid": agent_id}
|
|
94
|
+
encoded_header = _base64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
|
95
|
+
encoded_payload = _base64url_encode(json.dumps(claims, separators=(",", ":")).encode())
|
|
96
|
+
signing_input = f"{encoded_header}.{encoded_payload}".encode()
|
|
97
|
+
signature = self._private_key.sign(signing_input)
|
|
98
|
+
return f"{encoded_header}.{encoded_payload}.{_base64url_encode(signature)}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class NonceStore(Protocol):
|
|
102
|
+
def check_and_insert(self, scope: tuple[AgentId, str, str | None, str]) -> None: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MemoryNonceStore:
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self._seen: set[tuple[AgentId, str, str | None, str]] = set()
|
|
108
|
+
|
|
109
|
+
def check_and_insert(self, scope: tuple[AgentId, str, str | None, str]) -> None:
|
|
110
|
+
if scope in self._seen:
|
|
111
|
+
raise AgentProtocolError("nonce_reused", "nonce was already used in this replay scope")
|
|
112
|
+
self._seen.add(scope)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_event(protocol: str, event_type: str, actor: AgentId, created_at: int, nonce: str, payload: Any) -> Event:
|
|
116
|
+
validate_agent_id(actor)
|
|
117
|
+
return {
|
|
118
|
+
"protocol": protocol,
|
|
119
|
+
"type": event_type,
|
|
120
|
+
"actor": actor,
|
|
121
|
+
"created_at": created_at,
|
|
122
|
+
"nonce": nonce,
|
|
123
|
+
"payload": payload,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def with_room_id(event: Event, room_id: str) -> Event:
|
|
128
|
+
next_event = dict(event)
|
|
129
|
+
next_event["room_id"] = room_id
|
|
130
|
+
return next_event
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def canonical_event_bytes(event: Event) -> bytes:
|
|
134
|
+
canonical = rfc8785.dumps(event)
|
|
135
|
+
return canonical if isinstance(canonical, bytes) else canonical.encode()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def event_id(event: Event) -> str:
|
|
139
|
+
digest = hashlib.sha256(canonical_event_bytes(event)).digest()
|
|
140
|
+
return f"{EVENT_ID_PREFIX}{_base58btc_encode(digest)}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sign_event(private_key: Ed25519PrivateKey, event: Event) -> str:
|
|
144
|
+
return _base64url_encode(private_key.sign(canonical_event_bytes(event)))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def verify_event_id(envelope: Envelope) -> None:
|
|
148
|
+
expected = event_id(envelope["event"])
|
|
149
|
+
actual = envelope["event_id"]
|
|
150
|
+
if expected != actual:
|
|
151
|
+
raise AgentProtocolError("invalid_event_id", f"invalid event id: expected {expected}, got {actual}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def verify_signature(envelope: Envelope) -> None:
|
|
155
|
+
signature = _base64url_decode(envelope["signature"])
|
|
156
|
+
if len(signature) != 64:
|
|
157
|
+
raise AgentProtocolError("invalid_signature", f"signature must be 64 bytes, got {len(signature)}")
|
|
158
|
+
public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes(envelope["event"]["actor"]))
|
|
159
|
+
try:
|
|
160
|
+
public_key.verify(signature, canonical_event_bytes(envelope["event"]))
|
|
161
|
+
except InvalidSignature as exc:
|
|
162
|
+
raise AgentProtocolError("invalid_signature", "signature verification failed") from exc
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def verify_envelope(envelope: Envelope) -> None:
|
|
166
|
+
verify_event_id(envelope)
|
|
167
|
+
verify_signature(envelope)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def verify_timestamp(created_at: int, now_ms: int, window_ms: int) -> None:
|
|
171
|
+
if window_ms < 0 or abs(created_at - now_ms) > window_ms:
|
|
172
|
+
raise AgentProtocolError("timestamp_out_of_window", "timestamp is outside the allowed live-write window")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def nonce_scope_for_event(event: Event, kind: str = "actor_protocol") -> tuple[AgentId, str, str | None, str]:
|
|
176
|
+
room_id = event.get("room_id") if kind == "actor_room" else None
|
|
177
|
+
return (event["actor"], event["protocol"], room_id, event["nonce"])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def verify_live_envelope(envelope: Envelope, nonce_store: NonceStore, *, now_ms: int | None = None, window_ms: int = DEFAULT_LIVE_WRITE_WINDOW_MS, nonce_scope: str = "actor_protocol") -> None:
|
|
181
|
+
verify_envelope(envelope)
|
|
182
|
+
verify_timestamp(envelope["event"]["created_at"], now_ms if now_ms is not None else unix_time_millis(), window_ms)
|
|
183
|
+
nonce_store.check_and_insert(nonce_scope_for_event(envelope["event"], nonce_scope))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def create_request_jwt_claims(agent_id: AgentId, binding: RequestBinding, issued_at: int, ttl_secs: int, jti: str) -> dict[str, Any]:
|
|
187
|
+
return {
|
|
188
|
+
"iss": agent_id,
|
|
189
|
+
"sub": agent_id,
|
|
190
|
+
"aud": binding.audience,
|
|
191
|
+
"iat": issued_at,
|
|
192
|
+
"exp": issued_at + ttl_secs,
|
|
193
|
+
"jti": jti,
|
|
194
|
+
"method": binding.method.upper(),
|
|
195
|
+
"host": binding.host,
|
|
196
|
+
"path": binding.path,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def verify_request_jwt(token: str, *, audience: str, method: str, host: str, path: str, now_secs: int | None = None, max_ttl_secs: int = DEFAULT_REQUEST_JWT_TTL_SECS) -> dict[str, Any]:
|
|
201
|
+
parts = token.split(".")
|
|
202
|
+
if len(parts) != 3:
|
|
203
|
+
raise AgentProtocolError("invalid_jwt", "expected three compact JWS parts")
|
|
204
|
+
header = json.loads(_base64url_decode(parts[0]))
|
|
205
|
+
claims = json.loads(_base64url_decode(parts[1]))
|
|
206
|
+
signature = _base64url_decode(parts[2])
|
|
207
|
+
signing_input = f"{parts[0]}.{parts[1]}".encode()
|
|
208
|
+
|
|
209
|
+
if header.get("alg") != "EdDSA":
|
|
210
|
+
raise AgentProtocolError("invalid_jwt_claim", "alg must be EdDSA")
|
|
211
|
+
if header.get("typ") != "JWT":
|
|
212
|
+
raise AgentProtocolError("invalid_jwt_claim", "typ must be JWT")
|
|
213
|
+
if header.get("kid") != claims.get("iss") or claims.get("iss") != claims.get("sub"):
|
|
214
|
+
raise AgentProtocolError("invalid_jwt_claim", "kid, iss, and sub must identify the same Agent ID")
|
|
215
|
+
|
|
216
|
+
public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes(header["kid"]))
|
|
217
|
+
try:
|
|
218
|
+
public_key.verify(signature, signing_input)
|
|
219
|
+
except InvalidSignature as exc:
|
|
220
|
+
raise AgentProtocolError("invalid_signature", "JWT signature verification failed") from exc
|
|
221
|
+
|
|
222
|
+
expected_method = method.upper()
|
|
223
|
+
if claims.get("aud") != audience:
|
|
224
|
+
raise AgentProtocolError("invalid_jwt_claim", "aud mismatch")
|
|
225
|
+
if claims.get("method") != expected_method:
|
|
226
|
+
raise AgentProtocolError("invalid_jwt_claim", "method mismatch")
|
|
227
|
+
if claims.get("host") != host:
|
|
228
|
+
raise AgentProtocolError("invalid_jwt_claim", "host mismatch")
|
|
229
|
+
if claims.get("path") != path:
|
|
230
|
+
raise AgentProtocolError("invalid_jwt_claim", "path mismatch")
|
|
231
|
+
|
|
232
|
+
now = now_secs if now_secs is not None else unix_time_secs()
|
|
233
|
+
if claims["iat"] > now or claims["exp"] < now:
|
|
234
|
+
raise AgentProtocolError("invalid_jwt_claim", "iat/exp outside valid time window")
|
|
235
|
+
if claims["exp"] - claims["iat"] > max_ttl_secs:
|
|
236
|
+
raise AgentProtocolError("invalid_jwt_claim", "JWT ttl exceeds maximum")
|
|
237
|
+
return claims
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def verify_request_jwt_live(token: str, nonce_store: NonceStore, **context: Any) -> dict[str, Any]:
|
|
241
|
+
claims = verify_request_jwt(token, **context)
|
|
242
|
+
nonce_store.check_and_insert((claims["iss"], _REQUEST_AUTH_REPLAY_SCOPE, None, claims["jti"]))
|
|
243
|
+
return claims
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def unix_time_millis() -> int:
|
|
247
|
+
return int(time.time() * 1000)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def unix_time_secs() -> int:
|
|
251
|
+
return int(time.time())
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def random_nonce(prefix: str = "n_") -> str:
|
|
255
|
+
return f"{prefix}{_base64url_encode(os.urandom(16))}"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _base58btc_encode(data: bytes) -> str:
|
|
259
|
+
return "z" + base58.b58encode(data).decode()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _base58btc_decode(value: str) -> bytes:
|
|
263
|
+
if not value.startswith("z"):
|
|
264
|
+
raise AgentProtocolError("invalid_encoding", "expected base58btc multibase value")
|
|
265
|
+
return base58.b58decode(value[1:])
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _base64url_encode(data: bytes) -> str:
|
|
269
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _base64url_decode(value: str) -> bytes:
|
|
273
|
+
padding = "=" * ((4 - len(value) % 4) % 4)
|
|
274
|
+
return base64.urlsafe_b64decode(value + padding)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import AgentProtocolError
|
|
6
|
+
from .identity import AgentId, Envelope, Event, create_event, verify_envelope
|
|
7
|
+
|
|
8
|
+
PROFILE_PROTOCOL = "agent-profile/1.0"
|
|
9
|
+
PROFILE_UPDATE = "profile.update"
|
|
10
|
+
|
|
11
|
+
ProfileUpdatePayload = dict[str, Any]
|
|
12
|
+
AgentProfile = dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def profile_update_event(actor: AgentId, created_at: int, nonce: str, payload: ProfileUpdatePayload) -> Event:
|
|
16
|
+
return create_event(PROFILE_PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_profile_update(envelope: Envelope) -> None:
|
|
20
|
+
verify_envelope(envelope)
|
|
21
|
+
event = envelope["event"]
|
|
22
|
+
if event["protocol"] != PROFILE_PROTOCOL:
|
|
23
|
+
raise AgentProtocolError("invalid_event_protocol", f"expected {PROFILE_PROTOCOL}, got {event['protocol']}")
|
|
24
|
+
if event["type"] != PROFILE_UPDATE:
|
|
25
|
+
raise AgentProtocolError("invalid_event_type", f"expected {PROFILE_UPDATE}, got {event['type']}")
|
|
26
|
+
if event["actor"] != event["payload"].get("agent_id"):
|
|
27
|
+
raise AgentProtocolError("invalid_actor", "profile update actor must match payload.agent_id")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def materialize_profile(envelope: Envelope) -> AgentProfile:
|
|
31
|
+
validate_profile_update(envelope)
|
|
32
|
+
payload = envelope["event"]["payload"]
|
|
33
|
+
return {
|
|
34
|
+
"agent_id": payload["agent_id"],
|
|
35
|
+
"name": payload["name"],
|
|
36
|
+
"description": payload.get("description"),
|
|
37
|
+
"avatar_url": payload.get("avatar_url"),
|
|
38
|
+
"provider": payload.get("provider"),
|
|
39
|
+
"capabilities": payload.get("capabilities", []),
|
|
40
|
+
"service_endpoints": payload.get("service_endpoints", []),
|
|
41
|
+
"links": payload.get("links", {}),
|
|
42
|
+
"metadata": payload.get("metadata", {}),
|
|
43
|
+
"updated_at": envelope["event"]["created_at"],
|
|
44
|
+
"profile_event_id": envelope["event_id"],
|
|
45
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-protocols
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
5
|
+
Author: LDCLabs
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: agent,protocol,ed25519,sdk
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: base58<3,>=2.1
|
|
11
|
+
Requires-Dist: cryptography>=42
|
|
12
|
+
Requires-Dist: rfc8785<0.2,>=0.1.4
|
|
13
|
+
Provides-Extra: http
|
|
14
|
+
Requires-Dist: requests<3,>=2.31; extra == "http"
|
|
15
|
+
|
|
16
|
+
# agent-protocols Python SDK
|
|
17
|
+
|
|
18
|
+
Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
|
|
19
|
+
|
|
20
|
+
## Modules
|
|
21
|
+
|
|
22
|
+
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
23
|
+
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
24
|
+
- `agent_protocols.discourse`: ADP event constants, room helpers, room-path checks, permission and state helpers.
|
|
25
|
+
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
26
|
+
|
|
27
|
+
## Example
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
|
|
31
|
+
|
|
32
|
+
signer = AgentSigner.generate()
|
|
33
|
+
event = profile_update_event(
|
|
34
|
+
signer.agent_id(),
|
|
35
|
+
unix_time_millis(),
|
|
36
|
+
"n_01J8Z6",
|
|
37
|
+
{"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
38
|
+
)
|
|
39
|
+
envelope = signer.sign_event(event)
|
|
40
|
+
profile = materialize_profile(envelope)
|
|
41
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/agent_protocols/__init__.py
|
|
4
|
+
src/agent_protocols/discourse.py
|
|
5
|
+
src/agent_protocols/errors.py
|
|
6
|
+
src/agent_protocols/http_client.py
|
|
7
|
+
src/agent_protocols/identity.py
|
|
8
|
+
src/agent_protocols/profile.py
|
|
9
|
+
src/agent_protocols.egg-info/PKG-INFO
|
|
10
|
+
src/agent_protocols.egg-info/SOURCES.txt
|
|
11
|
+
src/agent_protocols.egg-info/dependency_links.txt
|
|
12
|
+
src/agent_protocols.egg-info/requires.txt
|
|
13
|
+
src/agent_protocols.egg-info/top_level.txt
|
|
14
|
+
tests/test_discourse.py
|
|
15
|
+
tests/test_identity.py
|
|
16
|
+
tests/test_profile.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_protocols
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from agent_protocols.discourse import (
|
|
4
|
+
MESSAGE_TEXT,
|
|
5
|
+
REACTION_CREATE,
|
|
6
|
+
ROOM_CANCEL,
|
|
7
|
+
ROOM_CREATE,
|
|
8
|
+
can_accept_room_write,
|
|
9
|
+
can_submit_event,
|
|
10
|
+
room_create_event,
|
|
11
|
+
validate_discourse_envelope,
|
|
12
|
+
)
|
|
13
|
+
from agent_protocols.identity import AgentSigner, create_event
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DiscourseTests(unittest.TestCase):
|
|
17
|
+
def test_validates_room_create_without_room_id(self):
|
|
18
|
+
signer = AgentSigner.from_seed(bytes([14]) * 32)
|
|
19
|
+
event = room_create_event(
|
|
20
|
+
signer.agent_id(),
|
|
21
|
+
100,
|
|
22
|
+
"n_room",
|
|
23
|
+
{"topic": "Research room", "visibility": "public", "start_time": 1000, "end_time": 2000},
|
|
24
|
+
)
|
|
25
|
+
envelope = signer.sign_event(event)
|
|
26
|
+
|
|
27
|
+
validate_discourse_envelope(envelope)
|
|
28
|
+
|
|
29
|
+
def test_rejects_room_event_without_room_id(self):
|
|
30
|
+
signer = AgentSigner.from_seed(bytes([15]) * 32)
|
|
31
|
+
event = create_event("agent-discourse/1.0", MESSAGE_TEXT, signer.agent_id(), 100, "n_message", {"text": "hello"})
|
|
32
|
+
envelope = signer.sign_event(event)
|
|
33
|
+
|
|
34
|
+
with self.assertRaises(Exception):
|
|
35
|
+
validate_discourse_envelope(envelope)
|
|
36
|
+
|
|
37
|
+
def test_applies_permission_matrix(self):
|
|
38
|
+
self.assertTrue(can_submit_event(REACTION_CREATE, {"role": "observer"}))
|
|
39
|
+
self.assertFalse(can_submit_event(MESSAGE_TEXT, {"role": "observer"}))
|
|
40
|
+
self.assertFalse(can_submit_event(ROOM_CANCEL, {"role": "moderator"}))
|
|
41
|
+
self.assertTrue(can_submit_event(ROOM_CANCEL, {"role": "moderator", "moderator_authorized": True}))
|
|
42
|
+
self.assertTrue(can_submit_event(ROOM_CREATE, {}))
|
|
43
|
+
|
|
44
|
+
def test_applies_state_restrictions(self):
|
|
45
|
+
self.assertTrue(can_accept_room_write(MESSAGE_TEXT, "active", {"role": "participant"}))
|
|
46
|
+
self.assertFalse(can_accept_room_write(MESSAGE_TEXT, "scheduled", {"role": "participant"}))
|
|
47
|
+
self.assertFalse(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}))
|
|
48
|
+
self.assertTrue(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}, post_end_reaction_allowed=True))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
unittest.main()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from agent_protocols.identity import (
|
|
4
|
+
AgentSigner,
|
|
5
|
+
MemoryNonceStore,
|
|
6
|
+
RequestBinding,
|
|
7
|
+
create_event,
|
|
8
|
+
create_request_jwt_claims,
|
|
9
|
+
verify_envelope,
|
|
10
|
+
verify_live_envelope,
|
|
11
|
+
verify_request_jwt_live,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IdentityTests(unittest.TestCase):
|
|
16
|
+
def test_signs_and_verifies_event_envelopes(self):
|
|
17
|
+
signer = AgentSigner.from_seed(bytes([7]) * 32)
|
|
18
|
+
event = create_event(
|
|
19
|
+
"agent-profile/1.0",
|
|
20
|
+
"profile.update",
|
|
21
|
+
signer.agent_id(),
|
|
22
|
+
1_779_753_600_000,
|
|
23
|
+
"n_test",
|
|
24
|
+
{"agent_id": signer.agent_id(), "name": "ResearchAgent"},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
envelope = signer.sign_event(event)
|
|
28
|
+
|
|
29
|
+
self.assertTrue(envelope["event_id"].startswith("evt_z"))
|
|
30
|
+
verify_envelope(envelope)
|
|
31
|
+
|
|
32
|
+
def test_rejects_tampered_payloads(self):
|
|
33
|
+
signer = AgentSigner.from_seed(bytes([8]) * 32)
|
|
34
|
+
envelope = signer.sign_event(
|
|
35
|
+
create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, "n_test", {"name": "before"})
|
|
36
|
+
)
|
|
37
|
+
envelope["event"]["payload"] = {"name": "after"}
|
|
38
|
+
|
|
39
|
+
with self.assertRaises(Exception):
|
|
40
|
+
verify_envelope(envelope)
|
|
41
|
+
|
|
42
|
+
def test_rejects_nonce_reuse(self):
|
|
43
|
+
signer = AgentSigner.from_seed(bytes([9]) * 32)
|
|
44
|
+
envelope = signer.sign_event(
|
|
45
|
+
create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, "n_reused", {"name": "ResearchAgent"})
|
|
46
|
+
)
|
|
47
|
+
store = MemoryNonceStore()
|
|
48
|
+
|
|
49
|
+
verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000)
|
|
50
|
+
with self.assertRaises(Exception):
|
|
51
|
+
verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000)
|
|
52
|
+
|
|
53
|
+
def test_signs_and_verifies_request_jwts(self):
|
|
54
|
+
signer = AgentSigner.from_seed(bytes([10]) * 32)
|
|
55
|
+
binding = RequestBinding.create("https://api.example.com", "get", "api.example.com", "/v1/profiles/did:agent:test")
|
|
56
|
+
claims = create_request_jwt_claims(signer.agent_id(), binding, 100, 300, "jwt_nonce")
|
|
57
|
+
token = signer.sign_request_jwt(claims)
|
|
58
|
+
store = MemoryNonceStore()
|
|
59
|
+
|
|
60
|
+
verified = verify_request_jwt_live(
|
|
61
|
+
token,
|
|
62
|
+
store,
|
|
63
|
+
audience=binding.audience,
|
|
64
|
+
method=binding.method,
|
|
65
|
+
host=binding.host,
|
|
66
|
+
path=binding.path,
|
|
67
|
+
now_secs=120,
|
|
68
|
+
max_ttl_secs=300,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self.assertEqual(verified["jti"], "jwt_nonce")
|
|
72
|
+
with self.assertRaises(Exception):
|
|
73
|
+
verify_request_jwt_live(
|
|
74
|
+
token,
|
|
75
|
+
store,
|
|
76
|
+
audience=binding.audience,
|
|
77
|
+
method=binding.method,
|
|
78
|
+
host=binding.host,
|
|
79
|
+
path=binding.path,
|
|
80
|
+
now_secs=120,
|
|
81
|
+
max_ttl_secs=300,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
unittest.main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from agent_protocols.identity import AgentSigner
|
|
4
|
+
from agent_protocols.profile import materialize_profile, profile_update_event, validate_profile_update
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ProfileTests(unittest.TestCase):
|
|
8
|
+
def test_materializes_valid_profile_update(self):
|
|
9
|
+
signer = AgentSigner.from_seed(bytes([11]) * 32)
|
|
10
|
+
payload = {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3", "capabilities": ["research"]}
|
|
11
|
+
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, "n_profile", payload))
|
|
12
|
+
|
|
13
|
+
profile = materialize_profile(envelope)
|
|
14
|
+
|
|
15
|
+
self.assertEqual(profile["name"], "ResearchAgent-v3")
|
|
16
|
+
self.assertEqual(profile["updated_at"], 1_779_753_600_000)
|
|
17
|
+
self.assertEqual(profile["profile_event_id"], envelope["event_id"])
|
|
18
|
+
|
|
19
|
+
def test_rejects_actor_payload_mismatch(self):
|
|
20
|
+
signer = AgentSigner.from_seed(bytes([12]) * 32)
|
|
21
|
+
other = AgentSigner.from_seed(bytes([13]) * 32)
|
|
22
|
+
payload = {"agent_id": other.agent_id(), "name": "Imposter"}
|
|
23
|
+
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, "n_profile", payload))
|
|
24
|
+
|
|
25
|
+
with self.assertRaises(Exception):
|
|
26
|
+
validate_profile_update(envelope)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
unittest.main()
|