agent-protocols 0.2.0__tar.gz → 0.2.2__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.
Files changed (18) hide show
  1. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/PKG-INFO +1 -1
  2. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/pyproject.toml +1 -1
  3. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols/discourse.py +139 -3
  4. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols/identity.py +7 -1
  5. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols/profile.py +2 -2
  6. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/PKG-INFO +1 -1
  7. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/tests/test_discourse.py +78 -0
  8. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/tests/test_identity.py +29 -1
  9. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/tests/test_profile.py +3 -5
  10. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/README.md +0 -0
  11. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/setup.cfg +0 -0
  12. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols/__init__.py +0 -0
  13. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols/errors.py +0 -0
  14. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols/http_client.py +0 -0
  15. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
  16. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  17. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/requires.txt +0 -0
  18. {agent_protocols-0.2.0 → agent_protocols-0.2.2}/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.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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"
@@ -1,12 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
4
+ import hashlib
3
5
  from typing import Any, Literal, TypedDict
4
6
 
7
+ import rfc8785
8
+
5
9
  from .errors import AgentProtocolError
6
10
  from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
7
11
 
8
12
  DISCOURSE_PROTOCOL = "agent-discourse/1.0"
9
- LEGACY_DISCOURSE_PROTOCOL = "adp/1.0"
10
13
 
11
14
  ROOM_CREATE = "room.create"
12
15
  ROOM_JOIN = "room.join"
@@ -74,11 +77,11 @@ def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int
74
77
  return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
75
78
 
76
79
 
77
- def validate_discourse_envelope(envelope: Envelope, accept_legacy_protocol: bool = False) -> None:
80
+ def validate_discourse_envelope(envelope: Envelope) -> None:
78
81
  verify_envelope(envelope)
79
82
  event = envelope["event"]
80
83
  protocol = event["protocol"]
81
- if protocol != DISCOURSE_PROTOCOL and not (accept_legacy_protocol and protocol == LEGACY_DISCOURSE_PROTOCOL):
84
+ if protocol != DISCOURSE_PROTOCOL:
82
85
  raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
83
86
  if event_requires_room_id(event["type"]) and "room_id" not in event:
84
87
  raise AgentProtocolError("missing_room_id", "event requires a room_id")
@@ -98,6 +101,132 @@ def event_requires_room_id(event_type: str) -> bool:
98
101
  return event_type != ROOM_CREATE
99
102
 
100
103
 
104
+ def validate_room_create_payload(payload: dict[str, Any]) -> None:
105
+ if not str(payload.get("topic", "")).strip():
106
+ raise AgentProtocolError("invalid_room", "room topic must not be empty")
107
+ if payload.get("start_time", 0) >= payload.get("end_time", 0):
108
+ raise AgentProtocolError("invalid_room", "start_time must be before end_time")
109
+ policy = payload.get("policy") or {}
110
+ max_participants = policy.get("max_participants")
111
+ if max_participants is not None and (
112
+ not isinstance(max_participants, int) or max_participants < 1
113
+ ):
114
+ raise AgentProtocolError("invalid_room", "max_participants must be a positive integer")
115
+
116
+
117
+ def validate_poll_create_payload(payload: dict[str, Any]) -> None:
118
+ if not str(payload.get("poll_id", "")).strip() or not str(payload.get("question", "")).strip():
119
+ raise AgentProtocolError("invalid_poll", "poll_id and question are required")
120
+ options = payload.get("options", [])
121
+ if len(options) < 2:
122
+ raise AgentProtocolError("invalid_poll", "poll requires at least two options")
123
+ option_ids: set[str] = set()
124
+ for option in options:
125
+ option_id = str(option.get("id", ""))
126
+ label = str(option.get("label", ""))
127
+ if not option_id.strip() or not label.strip():
128
+ raise AgentProtocolError("invalid_poll", "option id and label are required")
129
+ if option_id in option_ids:
130
+ raise AgentProtocolError("invalid_poll", "poll option ids must be unique")
131
+ option_ids.add(option_id)
132
+ min_choices = payload.get("min_choices", 1)
133
+ max_choices = payload.get("max_choices", 1)
134
+ if min_choices < 1 or max_choices < min_choices:
135
+ raise AgentProtocolError("invalid_poll", "invalid poll choice limits")
136
+
137
+
138
+ def validate_poll_vote_payload(payload: dict[str, Any], poll: dict[str, Any], now_ms: int | None = None) -> None:
139
+ if poll.get("closes_at") is not None and now_ms is not None and now_ms > poll["closes_at"]:
140
+ raise AgentProtocolError("poll_closed", "poll is closed")
141
+ min_choices = poll.get("min_choices", 1)
142
+ max_choices = poll.get("max_choices", 1)
143
+ option_ids = {option["id"] for option in poll.get("options", [])}
144
+ selected = payload.get("option_ids", [])
145
+ selected_set = set(selected)
146
+ if len(selected_set) != len(selected):
147
+ raise AgentProtocolError("invalid_poll_vote", "duplicate poll options")
148
+ if len(selected_set) < min_choices or len(selected_set) > max_choices:
149
+ raise AgentProtocolError("invalid_poll_vote", "invalid number of options")
150
+ if any(option_id not in option_ids for option_id in selected_set):
151
+ raise AgentProtocolError("invalid_poll_vote", "unknown poll option")
152
+
153
+
154
+ def server_record_hash_payload(
155
+ room_id: str,
156
+ seq: int,
157
+ pre_hash: str | None,
158
+ envelope_hash: str,
159
+ received_at: int,
160
+ ) -> dict[str, Any]:
161
+ return {
162
+ "room_id": room_id,
163
+ "seq": seq,
164
+ "pre_hash": pre_hash,
165
+ "envelope_hash": envelope_hash,
166
+ "received_at": received_at,
167
+ }
168
+
169
+
170
+ def server_record_hash(
171
+ room_id: str,
172
+ seq: int,
173
+ pre_hash: str | None,
174
+ envelope_hash: str,
175
+ received_at: int,
176
+ ) -> str:
177
+ return _hash_canonical_json(server_record_hash_payload(room_id, seq, pre_hash, envelope_hash, received_at))
178
+
179
+
180
+ def build_server_record(
181
+ room_id: str,
182
+ seq: int,
183
+ pre_hash: str | None,
184
+ received_at: int,
185
+ envelope: Envelope,
186
+ ) -> dict[str, Any]:
187
+ return {
188
+ "room_id": room_id,
189
+ "seq": seq,
190
+ "pre_hash": pre_hash,
191
+ "hash": server_record_hash(room_id, seq, pre_hash, envelope["hash"], received_at),
192
+ "received_at": received_at,
193
+ "envelope": envelope,
194
+ }
195
+
196
+
197
+ def verify_server_record(record: dict[str, Any]) -> None:
198
+ expected = server_record_hash(
199
+ record["room_id"],
200
+ record["seq"],
201
+ record.get("pre_hash"),
202
+ record["envelope"]["hash"],
203
+ record["received_at"],
204
+ )
205
+ if record["hash"] != expected:
206
+ raise AgentProtocolError("invalid_record_hash", f"invalid server record hash: expected {expected}, got {record['hash']}")
207
+
208
+
209
+ def verify_server_record_chain(records: list[dict[str, Any]]) -> None:
210
+ previous: dict[str, Any] | None = None
211
+ for record in records:
212
+ verify_server_record(record)
213
+ if previous is None:
214
+ if record["seq"] != 1:
215
+ raise AgentProtocolError("invalid_record_chain", "first seq must be 1")
216
+ if record.get("pre_hash") is not None:
217
+ raise AgentProtocolError("invalid_record_chain", "first pre_hash must be null")
218
+ else:
219
+ if record["seq"] != previous["seq"] + 1:
220
+ raise AgentProtocolError("invalid_record_chain", "seq must increase by 1")
221
+ if record.get("pre_hash") != previous["hash"]:
222
+ raise AgentProtocolError("invalid_record_chain", "pre_hash mismatch")
223
+ previous = record
224
+
225
+
226
+ def archive_events_digest(records: list[dict[str, Any]]) -> str:
227
+ return _hash_canonical_json(records)
228
+
229
+
101
230
  def can_submit_event(event_type: str, context: PermissionContext) -> bool:
102
231
  if event_type == ROOM_CREATE:
103
232
  return True
@@ -181,3 +310,10 @@ def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
181
310
  or (context.get("observer_steering_allowed", False) and event_type == ROOM_STEER)
182
311
  or (context.get("observer_poll_vote_allowed", False) and event_type == MESSAGE_POLL_VOTE)
183
312
  )
313
+
314
+
315
+ def _hash_canonical_json(value: Any) -> str:
316
+ canonical = rfc8785.dumps(value)
317
+ data = canonical if isinstance(canonical, bytes) else canonical.encode()
318
+ digest = hashlib.sha3_256(data).digest()
319
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
@@ -20,6 +20,7 @@ DEFAULT_LIVE_WRITE_WINDOW_MS = 300_000
20
20
  DEFAULT_NONCE_TTL_MS = 300_000
21
21
  DEFAULT_REQUEST_JWT_TTL_SECS = 300
22
22
  MAX_NONCE_HEADER = "Max-Seen-Nonce"
23
+ MAX_SAFE_NONCE = 0x1FFFFFFFFFFFFF
23
24
 
24
25
  Event = dict[str, Any]
25
26
  Envelope = dict[str, Any]
@@ -134,6 +135,7 @@ class ClientNonceManager:
134
135
 
135
136
  def next_nonce(self) -> int:
136
137
  nonce = self._next_nonce
138
+ validate_nonce(nonce)
137
139
  self._next_nonce += 1
138
140
  return nonce
139
141
 
@@ -174,11 +176,13 @@ def canonical_event_bytes(event: Event) -> bytes:
174
176
 
175
177
 
176
178
  def event_hash(event: Event) -> str:
179
+ validate_nonce(event["nonce"])
177
180
  digest = hashlib.sha3_256(canonical_event_bytes(event)).digest()
178
181
  return _base64url_encode(digest)
179
182
 
180
183
 
181
184
  def sign_event(private_key: Ed25519PrivateKey, event: Event) -> str:
185
+ validate_nonce(event["nonce"])
182
186
  return _base64url_encode(private_key.sign(canonical_event_bytes(event)))
183
187
 
184
188
 
@@ -253,6 +257,8 @@ def verify_request_jwt(token: str, *, audience: str, now_secs: int | None = None
253
257
  raise AgentProtocolError("invalid_jwt_claim", "aud mismatch")
254
258
 
255
259
  now = now_secs if now_secs is not None else unix_secs()
260
+ if claims["exp"] <= claims["iat"]:
261
+ raise AgentProtocolError("invalid_jwt_claim", "exp must be greater than iat")
256
262
  if claims["iat"] > now or claims["exp"] < now:
257
263
  raise AgentProtocolError("invalid_jwt_claim", "iat/exp outside valid time window")
258
264
  if claims["exp"] - claims["iat"] > max_ttl_secs:
@@ -269,7 +275,7 @@ def unix_secs() -> int:
269
275
 
270
276
 
271
277
  def validate_nonce(nonce: int) -> None:
272
- if not isinstance(nonce, int) or nonce < 1 or nonce > 0x1FFFFFFFFFFFFF:
278
+ if not isinstance(nonce, int) or nonce < 1 or nonce > MAX_SAFE_NONCE:
273
279
  raise AgentProtocolError("invalid_nonce", "nonce must be a positive integer less than or equal to 9007199254740991")
274
280
 
275
281
 
@@ -19,7 +19,7 @@ def profile_update_event(actor: AgentId, created_at: int, nonce: int, payload: P
19
19
  def validate_profile_update(envelope: Envelope) -> None:
20
20
  verify_envelope(envelope)
21
21
  event = envelope["event"]
22
- payload_id = event["payload"].get("id") or event["payload"].get("agent_id")
22
+ payload_id = event["payload"].get("id")
23
23
  if event["protocol"] != PROFILE_PROTOCOL:
24
24
  raise AgentProtocolError("invalid_event_protocol", f"expected {PROFILE_PROTOCOL}, got {event['protocol']}")
25
25
  if event["type"] != PROFILE_UPDATE:
@@ -31,7 +31,7 @@ def validate_profile_update(envelope: Envelope) -> None:
31
31
  def materialize_profile(envelope: Envelope) -> AgentProfile:
32
32
  validate_profile_update(envelope)
33
33
  payload = envelope["event"]["payload"]
34
- payload_id = payload.get("id") or payload.get("agent_id")
34
+ payload_id = payload.get("id")
35
35
  return {
36
36
  "id": payload_id,
37
37
  "name": payload["name"],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -7,11 +7,19 @@ from agent_protocols.discourse import (
7
7
  ROOM_CREATE,
8
8
  ROOM_JOIN,
9
9
  ROOM_JOIN_REVIEW,
10
+ archive_events_digest,
11
+ build_server_record,
10
12
  can_accept_room_write,
11
13
  can_submit_event,
12
14
  room_create_event,
15
+ server_record_hash,
16
+ validate_poll_create_payload,
17
+ validate_poll_vote_payload,
13
18
  validate_discourse_envelope,
19
+ validate_room_create_payload,
14
20
  validate_room_path,
21
+ verify_server_record,
22
+ verify_server_record_chain,
15
23
  )
16
24
  from agent_protocols.http_client import websocket_events_url
17
25
  from agent_protocols.identity import AgentSigner, create_event
@@ -65,6 +73,76 @@ class DiscourseTests(unittest.TestCase):
65
73
  self.assertFalse(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}))
66
74
  self.assertTrue(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}, post_end_reaction_allowed=True))
67
75
 
76
+ def test_validates_room_creation_payloads(self):
77
+ validate_room_create_payload(
78
+ {
79
+ "topic": "Research room",
80
+ "visibility": "public",
81
+ "start_time": 1000,
82
+ "end_time": 2000,
83
+ "policy": {"max_participants": 2},
84
+ }
85
+ )
86
+ with self.assertRaises(Exception):
87
+ validate_room_create_payload({"topic": " ", "visibility": "public", "start_time": 1000, "end_time": 2000})
88
+ with self.assertRaises(Exception):
89
+ validate_room_create_payload(
90
+ {"topic": "Research room", "visibility": "public", "start_time": 2000, "end_time": 1000}
91
+ )
92
+
93
+ def test_validates_poll_payloads_and_votes(self):
94
+ poll = {
95
+ "poll_id": "poll_review_order",
96
+ "question": "Which review order?",
97
+ "options": [{"id": "a", "label": "Correctness first"}, {"id": "b", "label": "Security first"}],
98
+ "min_choices": 1,
99
+ "max_choices": 1,
100
+ }
101
+
102
+ validate_poll_create_payload(poll)
103
+ validate_poll_vote_payload({"event_id": "evt", "option_ids": ["a"]}, poll)
104
+ with self.assertRaises(Exception):
105
+ validate_poll_vote_payload({"event_id": "evt", "option_ids": ["a", "b"]}, poll)
106
+ with self.assertRaises(Exception):
107
+ validate_poll_create_payload(
108
+ {
109
+ **poll,
110
+ "options": [{"id": "a", "label": "Correctness first"}, {"id": "a", "label": "Duplicate"}],
111
+ }
112
+ )
113
+
114
+ def test_builds_and_verifies_server_record_chains(self):
115
+ signer = AgentSigner.from_seed(bytes([18]) * 32)
116
+ envelope1 = signer.sign_event(
117
+ room_create_event(
118
+ signer.agent_id(),
119
+ 100,
120
+ 1,
121
+ {"topic": "Research room", "visibility": "public", "start_time": 1000, "end_time": 2000},
122
+ )
123
+ )
124
+ record1 = build_server_record("room123", 1, None, 110, envelope1)
125
+ event2 = create_event(
126
+ "agent-discourse/1.0",
127
+ MESSAGE_CREATE,
128
+ signer.agent_id(),
129
+ 120,
130
+ 2,
131
+ {"content_type": "text/plain", "content": "hello"},
132
+ )
133
+ event2["room_id"] = "room123"
134
+ envelope2 = signer.sign_event(event2)
135
+ record2 = build_server_record("room123", 2, record1["hash"], 130, envelope2)
136
+
137
+ self.assertEqual(record1["hash"], server_record_hash("room123", 1, None, envelope1["hash"], 110))
138
+ verify_server_record(record1)
139
+ verify_server_record_chain([record1, record2])
140
+ self.assertEqual(len(archive_events_digest([record1, record2])), 43)
141
+ with self.assertRaisesRegex(Exception, "first seq"):
142
+ verify_server_record_chain([record2])
143
+ with self.assertRaises(Exception):
144
+ verify_server_record_chain([{**record2, "pre_hash": "bad"}])
145
+
68
146
  def test_builds_websocket_event_stream_url(self):
69
147
  self.assertEqual(
70
148
  websocket_events_url("https://api.example.com", "room123", "jwt.token"),
@@ -3,6 +3,7 @@ import unittest
3
3
  from agent_protocols.identity import (
4
4
  AgentSigner,
5
5
  ClientNonceManager,
6
+ MAX_SAFE_NONCE,
6
7
  MemoryNonceStore,
7
8
  RequestBinding,
8
9
  create_event,
@@ -22,7 +23,7 @@ class IdentityTests(unittest.TestCase):
22
23
  signer.agent_id(),
23
24
  1_779_753_600_000,
24
25
  1,
25
- {"agent_id": signer.agent_id(), "name": "ResearchAgent"},
26
+ {"id": signer.agent_id(), "name": "ResearchAgent"},
26
27
  )
27
28
 
28
29
  envelope = signer.sign_event(event)
@@ -61,6 +62,19 @@ class IdentityTests(unittest.TestCase):
61
62
  self.assertEqual(manager.peek(), 6)
62
63
  self.assertEqual(manager.next_nonce(), 6)
63
64
 
65
+ def test_rejects_nonce_values_outside_safe_json_integer_range(self):
66
+ signer = AgentSigner.from_seed(bytes([16]) * 32)
67
+
68
+ with self.assertRaises(Exception):
69
+ create_event(
70
+ "agent-profile/1.0",
71
+ "profile.update",
72
+ signer.agent_id(),
73
+ 1000,
74
+ MAX_SAFE_NONCE + 1,
75
+ {"id": signer.agent_id(), "name": "ResearchAgent"},
76
+ )
77
+
64
78
  def test_signs_and_verifies_request_jwts(self):
65
79
  signer = AgentSigner.from_seed(bytes([10]) * 32)
66
80
  binding = RequestBinding.create("https://api.example.com")
@@ -76,6 +90,20 @@ class IdentityTests(unittest.TestCase):
76
90
 
77
91
  self.assertEqual(verified["iss"], signer.agent_id())
78
92
 
93
+ def test_rejects_request_jwts_with_non_positive_ttl(self):
94
+ signer = AgentSigner.from_seed(bytes([17]) * 32)
95
+ binding = RequestBinding.create("https://api.example.com")
96
+ claims = create_request_jwt_claims(signer.agent_id(), binding, 100, 0)
97
+ token = signer.sign_request_jwt(claims)
98
+
99
+ with self.assertRaises(Exception):
100
+ verify_request_jwt(
101
+ token,
102
+ audience=binding.audience,
103
+ now_secs=100,
104
+ max_ttl_secs=300,
105
+ )
106
+
79
107
 
80
108
  if __name__ == "__main__":
81
109
  unittest.main()
@@ -34,15 +34,13 @@ class ProfileTests(unittest.TestCase):
34
34
  with self.assertRaises(Exception):
35
35
  validate_profile_update(envelope)
36
36
 
37
- def test_materializes_legacy_agent_id_payload(self):
37
+ def test_rejects_legacy_agent_id_payload_without_id(self):
38
38
  signer = AgentSigner.from_seed(bytes([14]) * 32)
39
39
  payload = {"agent_id": signer.agent_id(), "name": "LegacyAgent"}
40
40
  envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_001, 1, payload))
41
41
 
42
- profile = materialize_profile(envelope)
43
-
44
- self.assertEqual(profile["id"], signer.agent_id())
45
- self.assertEqual(profile["name"], "LegacyAgent")
42
+ with self.assertRaises(Exception):
43
+ materialize_profile(envelope)
46
44
 
47
45
 
48
46
  if __name__ == "__main__":