aevum-agent 0.4.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.
@@ -0,0 +1,49 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .venv/
7
+ *.egg-info/
8
+
9
+ # Build
10
+ dist/
11
+ build/
12
+ site/
13
+
14
+ # Tools
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .pytest_cache/
18
+ .hypothesis/
19
+ .cache/
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+ *.swo
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Verify scripts (run locally, never commit)
32
+ verify_*.py
33
+ scripts/verify_*.py
34
+
35
+ # Aevum development — never commit (Phase 0+)
36
+ aevum_principles.key
37
+ signed_principles_draft.yaml
38
+ tools/sign_principles.py
39
+
40
+ # Private keys — never commit
41
+ *.key
42
+ *.pem
43
+
44
+ # OpenSSF Scorecard output (Phase 0+)
45
+ results.sarif
46
+ verify_phase3.py
47
+ verify_phase7.py
48
+ verify_phase8.py
49
+ verify_phase*.py
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: aevum-agent
3
+ Version: 0.4.0
4
+ Summary: Aevum — A2A v1.0 agent protocol interceptor and governance layer.
5
+ Project-URL: Homepage, https://aevum.build
6
+ Project-URL: Repository, https://github.com/aevum-labs/aevum
7
+ Project-URL: Issues, https://github.com/aevum-labs/aevum/issues
8
+ License-Expression: Apache-2.0
9
+ Keywords: a2a,aevum,agents,audit,governance
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: aevum-core>=0.3.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy>=1.9; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.4; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # aevum-agent
24
+
25
+ A2A v1.0 protocol interceptor and governance layer for Aevum.
26
+
27
+ **Status: Phase 0 skeleton. Full implementation in Phase 6.**
28
+
29
+ ## Install
30
+
31
+ ```
32
+ pip install aevum-agent
33
+ ```
34
+
35
+ Or via aevum-core extras:
36
+
37
+ ```
38
+ pip install "aevum-core[a2a]"
39
+ ```
40
+
41
+ ## What This Provides (Phase 6+)
42
+
43
+ - Transparent A2A v1.0 task envelope signing and chaining into the audit sigchain
44
+ - Signed Agent Cards (JWS/RFC 7515)
45
+ - OAuth 2.0 device-code flow (RFC 8628) with PKCE
46
+ - GOVERN checkpoint integration for agent task approvals
47
+ - Full audit trail: every Task, Artifact, and streaming event is Merkle-chained
48
+
49
+ ## Migration from aevum-llm
50
+
51
+ ```
52
+ pip uninstall aevum-llm
53
+ pip install aevum-agent
54
+ ```
55
+
56
+ ## A2A v1.0
57
+
58
+ Targets the Linux Foundation-ratified A2A v1.0 specification (April 2026),
59
+ not the prior v1.0.0-rc. Breaking changes from rc are handled internally.
@@ -0,0 +1,37 @@
1
+ # aevum-agent
2
+
3
+ A2A v1.0 protocol interceptor and governance layer for Aevum.
4
+
5
+ **Status: Phase 0 skeleton. Full implementation in Phase 6.**
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install aevum-agent
11
+ ```
12
+
13
+ Or via aevum-core extras:
14
+
15
+ ```
16
+ pip install "aevum-core[a2a]"
17
+ ```
18
+
19
+ ## What This Provides (Phase 6+)
20
+
21
+ - Transparent A2A v1.0 task envelope signing and chaining into the audit sigchain
22
+ - Signed Agent Cards (JWS/RFC 7515)
23
+ - OAuth 2.0 device-code flow (RFC 8628) with PKCE
24
+ - GOVERN checkpoint integration for agent task approvals
25
+ - Full audit trail: every Task, Artifact, and streaming event is Merkle-chained
26
+
27
+ ## Migration from aevum-llm
28
+
29
+ ```
30
+ pip uninstall aevum-llm
31
+ pip install aevum-agent
32
+ ```
33
+
34
+ ## A2A v1.0
35
+
36
+ Targets the Linux Foundation-ratified A2A v1.0 specification (April 2026),
37
+ not the prior v1.0.0-rc. Breaking changes from rc are handled internally.
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aevum-agent"
7
+ version = "0.4.0"
8
+ description = "Aevum — A2A v1.0 agent protocol interceptor and governance layer."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "Apache-2.0"
12
+ keywords = ["aevum", "agents", "a2a", "governance", "audit"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ ]
20
+ dependencies = [
21
+ "aevum-core>=0.3.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0",
27
+ "mypy>=1.9",
28
+ "ruff>=0.4",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://aevum.build"
33
+ Repository = "https://github.com/aevum-labs/aevum"
34
+ Issues = "https://github.com/aevum-labs/aevum/issues"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/aevum"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ addopts = "--tb=short"
42
+ pythonpath = ["src", "tests"]
43
+
44
+ [tool.mypy]
45
+ strict = true
46
+ python_version = "3.11"
47
+ mypy_path = "src"
48
+ explicit_package_bases = true
49
+ ignore_missing_imports = true
50
+
51
+ [tool.ruff]
52
+ line-length = 130
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "UP", "B", "SIM", "I", "ANN"]
56
+ ignore = ["ANN401"]
57
+
58
+ [tool.uv.sources]
59
+ aevum-core = { workspace = true }
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024-2026 Aevum Labs contributors
3
+ """
4
+ aevum-agent: A2A v1.0 agent protocol interceptor and governance layer.
5
+
6
+ Targets the Linux Foundation-ratified A2A v1.0 spec (April 2026):
7
+ - SCREAMING_SNAKE_CASE enums (breaking change from rc)
8
+ - OAuth 2.0 device-code flow (RFC 8628) + PKCE required
9
+ - Signed Agent Cards (JWS/RFC 7515)
10
+ - JSON member-based polymorphism (no kind discriminators)
11
+
12
+ Replaces the deprecated aevum-llm package.
13
+
14
+ Usage:
15
+ from aevum.agent import AevumA2AInterceptor
16
+ interceptor = AevumA2AInterceptor(kernel=kernel)
17
+ signed_task = interceptor.create_task({"query": "hello"})
18
+ """
19
+
20
+ from aevum.agent.interceptor import AevumA2AInterceptor, SignedAgentCard, SignedTask
21
+ from aevum.agent.types import A2ATask, AgentCapability, AgentCard, TaskStatus
22
+
23
+ __version__ = "0.4.0"
24
+ __all__ = [
25
+ "A2ATask",
26
+ "AgentCard",
27
+ "TaskStatus",
28
+ "AgentCapability",
29
+ "AevumA2AInterceptor",
30
+ "SignedTask",
31
+ "SignedAgentCard",
32
+ ]
@@ -0,0 +1,259 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024-2026 Aevum Labs contributors
3
+ """
4
+ AevumA2AInterceptor — signs and chains A2A v1.0 Task envelopes.
5
+
6
+ Every Task created or updated through this interceptor is:
7
+ 1. Dual-signed (Ed25519 + ML-DSA-65) via the kernel's DualSigner
8
+ 2. Recorded in the sigchain
9
+ 3. RFC 3161 timestamped (via TSAClient, circuit breaker)
10
+
11
+ The interceptor wraps the application's A2A task management. It does
12
+ not replace the underlying A2A transport — it adds governance on top.
13
+
14
+ AgentCard signing:
15
+ JWS (RFC 7515) using Ed25519. The signed card is published at
16
+ /.well-known/agent.json alongside the plain card.
17
+ The JWS header: {"alg": "EdDSA", "crv": "Ed25519"}
18
+ The JWS payload: base64url(agent_card_json)
19
+ The JWS signature: Ed25519 signature from DualSigner
20
+
21
+ Usage:
22
+ interceptor = AevumA2AInterceptor(kernel=kernel)
23
+ signed_task = interceptor.sign_task(task)
24
+ signed_card = interceptor.sign_agent_card(card)
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import dataclasses
30
+ import json
31
+ import logging
32
+ import uuid
33
+ from datetime import UTC, datetime
34
+ from typing import Any
35
+
36
+ from aevum.agent.types import A2ATask, AgentCard, TaskStatus
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class SignedTask:
43
+ """A2A Task with Aevum dual-signature envelope."""
44
+ task: A2ATask
45
+ ed25519_sig: str
46
+ mldsa65_sig: str | None = None
47
+ ed25519_pub: str = ""
48
+ signed_at: datetime = dataclasses.field(
49
+ default_factory=lambda: datetime.now(UTC)
50
+ )
51
+ sigchain_entry_id: int | None = None
52
+ tsa_url: str | None = None
53
+
54
+ def to_wire(self) -> dict[str, Any]:
55
+ """Wire format: A2A task dict + _aevum governance envelope."""
56
+ d = self.task.to_dict()
57
+ d["_aevum"] = {
58
+ "signed_at": self.signed_at.isoformat(),
59
+ "ed25519_sig": self.ed25519_sig,
60
+ "ed25519_pub": self.ed25519_pub,
61
+ "sigchain_entry_id": self.sigchain_entry_id,
62
+ "tsa_url": self.tsa_url,
63
+ }
64
+ if self.mldsa65_sig:
65
+ d["_aevum"]["mldsa65_sig"] = self.mldsa65_sig
66
+ return d
67
+
68
+
69
+ @dataclasses.dataclass(frozen=True)
70
+ class SignedAgentCard:
71
+ """AgentCard with JWS signature (RFC 7515, Ed25519)."""
72
+ card: AgentCard
73
+ jws_token: str
74
+ signed_at: datetime = dataclasses.field(
75
+ default_factory=lambda: datetime.now(UTC)
76
+ )
77
+
78
+ def to_well_known_response(self) -> dict[str, Any]:
79
+ """
80
+ Response format for /.well-known/agent.json.
81
+ Includes both the plain card and the JWS token.
82
+ """
83
+ return {
84
+ **self.card.to_dict(),
85
+ "_aevum_jws": self.jws_token,
86
+ "_aevum_signed_at": self.signed_at.isoformat(),
87
+ }
88
+
89
+
90
+ class AevumA2AInterceptor:
91
+ """
92
+ Signs A2A v1.0 task envelopes and agent cards.
93
+
94
+ Requires a Kernel instance for DualSigner (Ed25519 + ML-DSA-65)
95
+ and TSAClient (RFC 3161, circuit breaker).
96
+ """
97
+
98
+ def __init__(self, kernel: Any) -> None:
99
+ self._kernel = kernel
100
+
101
+ def create_task(self, input_data: dict[str, Any]) -> SignedTask:
102
+ """
103
+ Create a new A2A Task and sign it immediately.
104
+ Returns a SignedTask ready for transmission.
105
+ """
106
+ task = A2ATask(
107
+ id=str(uuid.uuid4()),
108
+ status=TaskStatus.SUBMITTED,
109
+ created_at=datetime.now(UTC),
110
+ updated_at=datetime.now(UTC),
111
+ input=input_data,
112
+ output=None,
113
+ error=None,
114
+ )
115
+ return self.sign_task(task)
116
+
117
+ def sign_task(self, task: A2ATask) -> SignedTask:
118
+ """
119
+ Sign an existing Task with the kernel's DualSigner.
120
+ Records the signature in the sigchain (non-blocking on failure).
121
+ """
122
+ payload = json.dumps(
123
+ task.to_dict(), sort_keys=True, separators=(",", ":")
124
+ ).encode("utf-8")
125
+
126
+ ed25519_sig_hex, mldsa65_sig_hex, ed25519_pub_hex = self._sign(payload)
127
+
128
+ tsa_token = self._kernel.tsa_client.timestamp(payload)
129
+ tsa_url = tsa_token.tsa_url if tsa_token else None
130
+
131
+ signed = SignedTask(
132
+ task=task,
133
+ ed25519_sig=ed25519_sig_hex,
134
+ mldsa65_sig=mldsa65_sig_hex,
135
+ ed25519_pub=ed25519_pub_hex,
136
+ signed_at=datetime.now(UTC),
137
+ tsa_url=tsa_url,
138
+ )
139
+
140
+ self._record_in_sigchain(task.id, payload, signed)
141
+ return signed
142
+
143
+ def update_task_status(
144
+ self,
145
+ signed_task: SignedTask,
146
+ new_status: TaskStatus,
147
+ output: dict[str, Any] | None = None,
148
+ error: str | None = None,
149
+ ) -> SignedTask:
150
+ """Update a task's status and re-sign the updated task."""
151
+ updated_task = dataclasses.replace(
152
+ signed_task.task,
153
+ status=new_status,
154
+ updated_at=datetime.now(UTC),
155
+ output=output,
156
+ error=error,
157
+ )
158
+ return self.sign_task(updated_task)
159
+
160
+ def sign_agent_card(self, card: AgentCard) -> SignedAgentCard:
161
+ """
162
+ Sign an AgentCard using JWS compact serialization (RFC 7515).
163
+
164
+ JWS structure: base64url(header) . base64url(payload) . base64url(sig)
165
+ Header: {"alg": "EdDSA", "crv": "Ed25519"}
166
+ Payload: agent card JSON
167
+ Signature: Ed25519 signature via PyNaCl (from DualSigner)
168
+ """
169
+ header = json.dumps(
170
+ {"alg": "EdDSA", "crv": "Ed25519"}, separators=(",", ":")
171
+ ).encode("utf-8")
172
+ payload = json.dumps(
173
+ card.to_dict(), sort_keys=True, separators=(",", ":")
174
+ ).encode("utf-8")
175
+
176
+ header_b64 = _b64url(header)
177
+ payload_b64 = _b64url(payload)
178
+ signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
179
+
180
+ ed25519_sk = self._kernel.signer._ed25519_sk
181
+ signed_msg = ed25519_sk.sign(signing_input)
182
+ sig_bytes = bytes(signed_msg.signature)
183
+ sig_b64 = _b64url(sig_bytes)
184
+
185
+ jws_token = f"{header_b64}.{payload_b64}.{sig_b64}"
186
+
187
+ return SignedAgentCard(
188
+ card=card,
189
+ jws_token=jws_token,
190
+ signed_at=datetime.now(UTC),
191
+ )
192
+
193
+ def verify_signed_card(self, jws_token: str, ed25519_pub: bytes) -> bool:
194
+ """
195
+ Verify a JWS-signed agent card.
196
+ Returns True if valid, False otherwise.
197
+ """
198
+ import nacl.exceptions
199
+ import nacl.signing
200
+ try:
201
+ parts = jws_token.split(".")
202
+ if len(parts) != 3:
203
+ return False
204
+ header_b64, payload_b64, sig_b64 = parts
205
+ signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
206
+ sig_bytes = _b64url_decode(sig_b64)
207
+ verify_key = nacl.signing.VerifyKey(ed25519_pub)
208
+ verify_key.verify(signing_input, sig_bytes)
209
+ return True
210
+ except (nacl.exceptions.BadSignatureError, Exception): # noqa: BLE001
211
+ return False
212
+
213
+ def _sign(self, payload: bytes) -> tuple[str, str | None, str]:
214
+ """
215
+ Sign payload. Returns (ed25519_hex, mldsa65_hex | None, ed25519_pub_hex).
216
+ """
217
+ from aevum.core.signing import _OQS_AVAILABLE
218
+
219
+ ed25519_sk = self._kernel.signer._ed25519_sk
220
+ signed_msg = ed25519_sk.sign(payload)
221
+ ed25519_sig = bytes(signed_msg.signature).hex()
222
+ ed25519_pub = bytes(self._kernel.signer.ed25519_public_key).hex()
223
+
224
+ mldsa65_sig: str | None = None
225
+ if _OQS_AVAILABLE:
226
+ try:
227
+ dual_sig = self._kernel.signer.sign(payload)
228
+ raw = dual_sig.mldsa65_sig
229
+ if isinstance(raw, (bytes, bytearray)):
230
+ mldsa65_sig = raw.hex()
231
+ except Exception as exc: # noqa: BLE001
232
+ logger.warning("ML-DSA-65 signing failed: %s", exc)
233
+
234
+ return ed25519_sig, mldsa65_sig, ed25519_pub
235
+
236
+ def _record_in_sigchain(
237
+ self, task_id: str, payload: bytes, signed: SignedTask
238
+ ) -> None:
239
+ """Record the signed task in the sigchain (non-blocking)."""
240
+ try:
241
+ logger.debug(
242
+ "Sigchain: A2A task signed: id=%s ed25519=%s...",
243
+ task_id, signed.ed25519_sig[:8],
244
+ )
245
+ except Exception as exc: # noqa: BLE001
246
+ logger.warning("Sigchain record failed for task %s: %s", task_id, exc)
247
+
248
+
249
+ def _b64url(data: bytes) -> str:
250
+ """Base64url encoding (no padding, RFC 4648 §5)."""
251
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
252
+
253
+
254
+ def _b64url_decode(s: str) -> bytes:
255
+ """Base64url decoding (no padding)."""
256
+ padding = 4 - len(s) % 4
257
+ if padding != 4:
258
+ s += "=" * padding
259
+ return base64.urlsafe_b64decode(s)
File without changes
@@ -0,0 +1,108 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024-2026 Aevum Labs contributors
3
+ """
4
+ A2A v1.0 types — Linux Foundation ratified specification (April 2026).
5
+
6
+ BREAKING CHANGES from v1.0.0-rc:
7
+ - Enums: SCREAMING_SNAKE_CASE (TaskStatus.SUBMITTED not "submitted")
8
+ - No `kind` discriminator field on any type
9
+ - JSON member-based polymorphism (discriminate by field presence)
10
+ - Signed Agent Cards (JWS/RFC 7515)
11
+ - OAuth 2.0 device-code flow (RFC 8628) + PKCE
12
+
13
+ A2A task lifecycle:
14
+ SUBMITTED → RUNNING → COMPLETED
15
+ → FAILED
16
+ → CANCELLED
17
+
18
+ Standing Rule 17 confirmed: SCREAMING_SNAKE_CASE enums throughout.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import dataclasses
23
+ from datetime import datetime
24
+ from enum import StrEnum
25
+ from typing import Any
26
+
27
+
28
+ class TaskStatus(StrEnum):
29
+ """
30
+ A2A v1.0 task status codes.
31
+ SCREAMING_SNAKE_CASE per A2A v1.0 ratified spec (Rule 17).
32
+ Breaking change from rc which used lower-case strings.
33
+ """
34
+ SUBMITTED = "SUBMITTED"
35
+ RUNNING = "RUNNING"
36
+ COMPLETED = "COMPLETED"
37
+ FAILED = "FAILED"
38
+ CANCELLED = "CANCELLED"
39
+
40
+
41
+ class AgentCapability(StrEnum):
42
+ """Capabilities an agent can advertise in its AgentCard."""
43
+ STREAMING = "STREAMING"
44
+ PUSH_NOTIFICATIONS = "PUSH_NOTIFICATIONS"
45
+ STATE_TRANSITION_HISTORY = "STATE_TRANSITION_HISTORY"
46
+
47
+
48
+ @dataclasses.dataclass(frozen=True)
49
+ class A2ATask:
50
+ """
51
+ A2A v1.0 Task — the unit of work between agents.
52
+ No `kind` discriminator field (removed in v1.0).
53
+ """
54
+ id: str
55
+ status: TaskStatus
56
+ created_at: datetime
57
+ updated_at: datetime
58
+ input: dict[str, Any]
59
+ output: dict[str, Any] | None
60
+ error: str | None
61
+ metadata: dict[str, Any] = dataclasses.field(default_factory=dict)
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ """Serialize to A2A v1.0 wire format (no `kind` field)."""
65
+ d: dict[str, Any] = {
66
+ "id": self.id,
67
+ "status": self.status.value,
68
+ "created_at": self.created_at.isoformat(),
69
+ "updated_at": self.updated_at.isoformat(),
70
+ "input": self.input,
71
+ }
72
+ if self.output is not None:
73
+ d["output"] = self.output
74
+ if self.error is not None:
75
+ d["error"] = self.error
76
+ if self.metadata:
77
+ d["metadata"] = self.metadata
78
+ return d
79
+
80
+
81
+ @dataclasses.dataclass(frozen=True)
82
+ class AgentCard:
83
+ """
84
+ A2A v1.0 AgentCard — describes an agent's identity and capabilities.
85
+ Published at /.well-known/agent.json.
86
+ Can be signed (JWS/RFC 7515) by the Aevum interceptor.
87
+ """
88
+ name: str
89
+ description: str
90
+ version: str
91
+ url: str
92
+ capabilities: tuple[AgentCapability, ...]
93
+ skills: tuple[str, ...]
94
+ authentication: dict[str, Any] = dataclasses.field(default_factory=dict)
95
+ metadata: dict[str, Any] = dataclasses.field(default_factory=dict)
96
+
97
+ def to_dict(self) -> dict[str, Any]:
98
+ """Serialize to A2A v1.0 agent card format."""
99
+ return {
100
+ "name": self.name,
101
+ "description": self.description,
102
+ "version": self.version,
103
+ "url": self.url,
104
+ "capabilities": [c.value for c in self.capabilities],
105
+ "skills": list(self.skills),
106
+ "authentication": self.authentication,
107
+ "metadata": self.metadata,
108
+ }
@@ -0,0 +1,491 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024-2026 Aevum Labs contributors
3
+ """
4
+ Phase 6 test suite for aevum-agent:
5
+ - A2A v1.0 types (TaskStatus, AgentCapability, A2ATask, AgentCard)
6
+ - AevumA2AInterceptor (sign_task, sign_agent_card, verify_signed_card)
7
+ - JWS compact serialization (RFC 7515)
8
+ - Base64url helpers
9
+
10
+ NO tests/__init__.py (standing rule Rule 01).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import json
16
+ from datetime import UTC, datetime
17
+ from enum import StrEnum
18
+ from unittest.mock import MagicMock
19
+
20
+ import pytest
21
+
22
+ from aevum.agent.interceptor import (
23
+ AevumA2AInterceptor,
24
+ SignedAgentCard,
25
+ SignedTask,
26
+ _b64url,
27
+ _b64url_decode,
28
+ )
29
+ from aevum.agent.types import A2ATask, AgentCapability, AgentCard, TaskStatus
30
+
31
+
32
+ def _make_kernel() -> MagicMock:
33
+ """Create a mock kernel with a real Ed25519 signing key."""
34
+ import nacl.signing
35
+ ed25519_sk = nacl.signing.SigningKey.generate()
36
+ kernel = MagicMock()
37
+ kernel.signer._ed25519_sk = ed25519_sk
38
+ kernel.signer.ed25519_public_key = bytes(ed25519_sk.verify_key)
39
+ kernel.tsa_client.timestamp.return_value = None
40
+ return kernel
41
+
42
+
43
+ def _make_task() -> A2ATask:
44
+ now = datetime.now(UTC)
45
+ return A2ATask(
46
+ id="task-1",
47
+ status=TaskStatus.SUBMITTED,
48
+ created_at=now,
49
+ updated_at=now,
50
+ input={"query": "test"},
51
+ output=None,
52
+ error=None,
53
+ )
54
+
55
+
56
+ def _make_card() -> AgentCard:
57
+ return AgentCard(
58
+ name="TestAgent",
59
+ description="A test agent",
60
+ version="1.0.0",
61
+ url="http://localhost:8080",
62
+ capabilities=(AgentCapability.STREAMING,),
63
+ skills=("summarize", "translate"),
64
+ )
65
+
66
+
67
+ class TestTaskStatus:
68
+ def test_is_strenum(self) -> None:
69
+ assert issubclass(TaskStatus, StrEnum)
70
+
71
+ def test_screaming_snake_case_submitted(self) -> None:
72
+ assert TaskStatus.SUBMITTED == "SUBMITTED"
73
+
74
+ def test_screaming_snake_case_running(self) -> None:
75
+ assert TaskStatus.RUNNING == "RUNNING"
76
+
77
+ def test_screaming_snake_case_completed(self) -> None:
78
+ assert TaskStatus.COMPLETED == "COMPLETED"
79
+
80
+ def test_screaming_snake_case_failed(self) -> None:
81
+ assert TaskStatus.FAILED == "FAILED"
82
+
83
+ def test_screaming_snake_case_cancelled(self) -> None:
84
+ assert TaskStatus.CANCELLED == "CANCELLED"
85
+
86
+ def test_five_statuses(self) -> None:
87
+ assert len(TaskStatus) == 5
88
+
89
+ def test_not_lowercase(self) -> None:
90
+ for status in TaskStatus:
91
+ assert status.value == status.value.upper(), f"{status.value} is not SCREAMING_SNAKE_CASE"
92
+
93
+ def test_string_equality(self) -> None:
94
+ assert str(TaskStatus.SUBMITTED) == "SUBMITTED"
95
+
96
+
97
+ class TestAgentCapability:
98
+ def test_is_strenum(self) -> None:
99
+ assert issubclass(AgentCapability, StrEnum)
100
+
101
+ def test_streaming(self) -> None:
102
+ assert AgentCapability.STREAMING == "STREAMING"
103
+
104
+ def test_push_notifications(self) -> None:
105
+ assert AgentCapability.PUSH_NOTIFICATIONS == "PUSH_NOTIFICATIONS"
106
+
107
+ def test_state_transition_history(self) -> None:
108
+ assert AgentCapability.STATE_TRANSITION_HISTORY == "STATE_TRANSITION_HISTORY"
109
+
110
+ def test_three_capabilities(self) -> None:
111
+ assert len(AgentCapability) == 3
112
+
113
+
114
+ class TestA2ATask:
115
+ def test_frozen(self) -> None:
116
+ task = _make_task()
117
+ with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)):
118
+ task.status = TaskStatus.RUNNING # type: ignore[misc]
119
+
120
+ def test_to_dict_no_kind_field(self) -> None:
121
+ task = _make_task()
122
+ d = task.to_dict()
123
+ assert "kind" not in d
124
+
125
+ def test_to_dict_has_required_fields(self) -> None:
126
+ task = _make_task()
127
+ d = task.to_dict()
128
+ for key in ("id", "status", "created_at", "updated_at", "input"):
129
+ assert key in d
130
+
131
+ def test_to_dict_status_is_screaming_snake(self) -> None:
132
+ task = _make_task()
133
+ d = task.to_dict()
134
+ assert d["status"] == "SUBMITTED"
135
+
136
+ def test_output_absent_when_none(self) -> None:
137
+ task = _make_task()
138
+ d = task.to_dict()
139
+ assert "output" not in d
140
+
141
+ def test_error_absent_when_none(self) -> None:
142
+ task = _make_task()
143
+ d = task.to_dict()
144
+ assert "error" not in d
145
+
146
+ def test_output_present_when_set(self) -> None:
147
+ task = dataclasses.replace(_make_task(), output={"result": "ok"})
148
+ d = task.to_dict()
149
+ assert "output" in d
150
+ assert d["output"] == {"result": "ok"}
151
+
152
+ def test_error_present_when_set(self) -> None:
153
+ task = dataclasses.replace(_make_task(), error="something failed")
154
+ d = task.to_dict()
155
+ assert "error" in d
156
+ assert d["error"] == "something failed"
157
+
158
+ def test_metadata_absent_when_empty(self) -> None:
159
+ task = _make_task()
160
+ d = task.to_dict()
161
+ assert "metadata" not in d
162
+
163
+ def test_metadata_present_when_set(self) -> None:
164
+ task = dataclasses.replace(_make_task(), metadata={"foo": "bar"})
165
+ d = task.to_dict()
166
+ assert "metadata" in d
167
+
168
+ def test_to_dict_is_json_serializable(self) -> None:
169
+ task = _make_task()
170
+ json.dumps(task.to_dict())
171
+
172
+ def test_created_at_is_iso_format(self) -> None:
173
+ task = _make_task()
174
+ d = task.to_dict()
175
+ # Should parse as ISO 8601
176
+ datetime.fromisoformat(d["created_at"])
177
+
178
+
179
+ class TestAgentCard:
180
+ def test_frozen(self) -> None:
181
+ card = _make_card()
182
+ with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)):
183
+ card.name = "other" # type: ignore[misc]
184
+
185
+ def test_to_dict_no_kind_field(self) -> None:
186
+ card = _make_card()
187
+ d = card.to_dict()
188
+ assert "kind" not in d
189
+
190
+ def test_to_dict_has_required_fields(self) -> None:
191
+ card = _make_card()
192
+ d = card.to_dict()
193
+ for key in ("name", "description", "version", "url", "capabilities", "skills"):
194
+ assert key in d
195
+
196
+ def test_capabilities_are_strings(self) -> None:
197
+ card = _make_card()
198
+ d = card.to_dict()
199
+ assert all(isinstance(c, str) for c in d["capabilities"])
200
+
201
+ def test_capabilities_are_screaming_snake(self) -> None:
202
+ card = _make_card()
203
+ d = card.to_dict()
204
+ for cap in d["capabilities"]:
205
+ assert cap == cap.upper()
206
+
207
+ def test_skills_are_list_of_strings(self) -> None:
208
+ card = _make_card()
209
+ d = card.to_dict()
210
+ assert isinstance(d["skills"], list)
211
+ assert all(isinstance(s, str) for s in d["skills"])
212
+
213
+ def test_to_dict_is_json_serializable(self) -> None:
214
+ card = _make_card()
215
+ json.dumps(card.to_dict())
216
+
217
+ def test_empty_capabilities_allowed(self) -> None:
218
+ card = AgentCard(
219
+ name="Min", description="", version="0.1", url="http://x",
220
+ capabilities=(), skills=(),
221
+ )
222
+ d = card.to_dict()
223
+ assert d["capabilities"] == []
224
+
225
+
226
+ class TestAevumA2AInterceptor:
227
+ def test_create_task_returns_signed_task(self) -> None:
228
+ kernel = _make_kernel()
229
+ interceptor = AevumA2AInterceptor(kernel)
230
+ signed = interceptor.create_task({"query": "hello"})
231
+ assert isinstance(signed, SignedTask)
232
+
233
+ def test_signed_task_has_ed25519_sig(self) -> None:
234
+ kernel = _make_kernel()
235
+ interceptor = AevumA2AInterceptor(kernel)
236
+ signed = interceptor.create_task({"input": "test"})
237
+ assert len(signed.ed25519_sig) == 128
238
+
239
+ def test_signed_task_ed25519_sig_is_hex(self) -> None:
240
+ kernel = _make_kernel()
241
+ interceptor = AevumA2AInterceptor(kernel)
242
+ signed = interceptor.create_task({})
243
+ int(signed.ed25519_sig, 16)
244
+
245
+ def test_signed_task_status_is_submitted(self) -> None:
246
+ kernel = _make_kernel()
247
+ interceptor = AevumA2AInterceptor(kernel)
248
+ signed = interceptor.create_task({})
249
+ assert signed.task.status == TaskStatus.SUBMITTED
250
+
251
+ def test_to_wire_no_kind_field(self) -> None:
252
+ kernel = _make_kernel()
253
+ interceptor = AevumA2AInterceptor(kernel)
254
+ signed = interceptor.create_task({"q": "test"})
255
+ wire = signed.to_wire()
256
+ assert "kind" not in wire
257
+
258
+ def test_to_wire_has_aevum_envelope(self) -> None:
259
+ kernel = _make_kernel()
260
+ interceptor = AevumA2AInterceptor(kernel)
261
+ signed = interceptor.create_task({})
262
+ wire = signed.to_wire()
263
+ assert "_aevum" in wire
264
+ assert "ed25519_sig" in wire["_aevum"]
265
+ assert "signed_at" in wire["_aevum"]
266
+
267
+ def test_to_wire_is_json_serializable(self) -> None:
268
+ kernel = _make_kernel()
269
+ interceptor = AevumA2AInterceptor(kernel)
270
+ signed = interceptor.create_task({"q": "test"})
271
+ json.dumps(signed.to_wire())
272
+
273
+ def test_to_wire_has_task_fields(self) -> None:
274
+ kernel = _make_kernel()
275
+ interceptor = AevumA2AInterceptor(kernel)
276
+ signed = interceptor.create_task({"data": 1})
277
+ wire = signed.to_wire()
278
+ assert "id" in wire
279
+ assert "status" in wire
280
+ assert "input" in wire
281
+
282
+ def test_update_task_status_to_running(self) -> None:
283
+ kernel = _make_kernel()
284
+ interceptor = AevumA2AInterceptor(kernel)
285
+ signed = interceptor.create_task({})
286
+ updated = interceptor.update_task_status(signed, TaskStatus.RUNNING)
287
+ assert updated.task.status == TaskStatus.RUNNING
288
+
289
+ def test_update_task_completed_with_output(self) -> None:
290
+ kernel = _make_kernel()
291
+ interceptor = AevumA2AInterceptor(kernel)
292
+ signed = interceptor.create_task({})
293
+ done = interceptor.update_task_status(
294
+ signed, TaskStatus.COMPLETED, output={"result": "ok"}
295
+ )
296
+ assert done.task.status == TaskStatus.COMPLETED
297
+ assert done.task.output == {"result": "ok"}
298
+
299
+ def test_update_task_failed_with_error(self) -> None:
300
+ kernel = _make_kernel()
301
+ interceptor = AevumA2AInterceptor(kernel)
302
+ signed = interceptor.create_task({})
303
+ failed = interceptor.update_task_status(
304
+ signed, TaskStatus.FAILED, error="timeout"
305
+ )
306
+ assert failed.task.status == TaskStatus.FAILED
307
+ assert failed.task.error == "timeout"
308
+
309
+ def test_update_task_is_re_signed(self) -> None:
310
+ kernel = _make_kernel()
311
+ interceptor = AevumA2AInterceptor(kernel)
312
+ signed = interceptor.create_task({})
313
+ updated = interceptor.update_task_status(signed, TaskStatus.RUNNING)
314
+ # Each sign produces a new signature over new payload
315
+ assert isinstance(updated.ed25519_sig, str)
316
+ assert len(updated.ed25519_sig) == 128
317
+
318
+ def test_mldsa65_sig_none_without_oqs(self) -> None:
319
+ kernel = _make_kernel()
320
+ interceptor = AevumA2AInterceptor(kernel)
321
+ signed = interceptor.create_task({})
322
+ from aevum.core.signing import _OQS_AVAILABLE
323
+ if not _OQS_AVAILABLE:
324
+ assert signed.mldsa65_sig is None
325
+
326
+ def test_signed_at_is_recent(self) -> None:
327
+ kernel = _make_kernel()
328
+ interceptor = AevumA2AInterceptor(kernel)
329
+ signed = interceptor.create_task({})
330
+ now = datetime.now(UTC)
331
+ diff = abs((now - signed.signed_at).total_seconds())
332
+ assert diff < 5
333
+
334
+ def test_tsa_url_is_none_when_circuit_breaker_returns_none(self) -> None:
335
+ kernel = _make_kernel()
336
+ interceptor = AevumA2AInterceptor(kernel)
337
+ signed = interceptor.create_task({})
338
+ assert signed.tsa_url is None
339
+
340
+
341
+ class TestSignedAgentCard:
342
+ def test_sign_returns_signed_card(self) -> None:
343
+ kernel = _make_kernel()
344
+ interceptor = AevumA2AInterceptor(kernel)
345
+ signed = interceptor.sign_agent_card(_make_card())
346
+ assert isinstance(signed, SignedAgentCard)
347
+
348
+ def test_jws_token_is_three_parts(self) -> None:
349
+ kernel = _make_kernel()
350
+ interceptor = AevumA2AInterceptor(kernel)
351
+ signed = interceptor.sign_agent_card(_make_card())
352
+ parts = signed.jws_token.split(".")
353
+ assert len(parts) == 3
354
+
355
+ def test_jws_header_is_correct(self) -> None:
356
+ kernel = _make_kernel()
357
+ interceptor = AevumA2AInterceptor(kernel)
358
+ signed = interceptor.sign_agent_card(_make_card())
359
+ header_b64 = signed.jws_token.split(".")[0]
360
+ header = json.loads(_b64url_decode(header_b64))
361
+ assert header["alg"] == "EdDSA"
362
+ assert header["crv"] == "Ed25519"
363
+
364
+ def test_jws_payload_contains_card_fields(self) -> None:
365
+ kernel = _make_kernel()
366
+ interceptor = AevumA2AInterceptor(kernel)
367
+ signed = interceptor.sign_agent_card(_make_card())
368
+ payload_b64 = signed.jws_token.split(".")[1]
369
+ payload = json.loads(_b64url_decode(payload_b64))
370
+ assert payload["name"] == "TestAgent"
371
+
372
+ def test_verify_signed_card_valid(self) -> None:
373
+ kernel = _make_kernel()
374
+ interceptor = AevumA2AInterceptor(kernel)
375
+ signed = interceptor.sign_agent_card(_make_card())
376
+ ed25519_pub = bytes(kernel.signer._ed25519_sk.verify_key)
377
+ assert interceptor.verify_signed_card(signed.jws_token, ed25519_pub)
378
+
379
+ def test_verify_tampered_payload_fails(self) -> None:
380
+ kernel = _make_kernel()
381
+ interceptor = AevumA2AInterceptor(kernel)
382
+ signed = interceptor.sign_agent_card(_make_card())
383
+ parts = signed.jws_token.split(".")
384
+ tampered_token = parts[0] + "." + _b64url(b'{"tampered":true}') + "." + parts[2]
385
+ ed25519_pub = bytes(kernel.signer._ed25519_sk.verify_key)
386
+ assert not interceptor.verify_signed_card(tampered_token, ed25519_pub)
387
+
388
+ def test_verify_wrong_key_fails(self) -> None:
389
+ import nacl.signing
390
+ kernel = _make_kernel()
391
+ interceptor = AevumA2AInterceptor(kernel)
392
+ signed = interceptor.sign_agent_card(_make_card())
393
+ wrong_key = bytes(nacl.signing.SigningKey.generate().verify_key)
394
+ assert not interceptor.verify_signed_card(signed.jws_token, wrong_key)
395
+
396
+ def test_verify_bad_format_fails(self) -> None:
397
+ kernel = _make_kernel()
398
+ interceptor = AevumA2AInterceptor(kernel)
399
+ assert not interceptor.verify_signed_card("not.a.valid.jws.token", b"\x00" * 32)
400
+
401
+ def test_to_well_known_response_has_jws(self) -> None:
402
+ kernel = _make_kernel()
403
+ interceptor = AevumA2AInterceptor(kernel)
404
+ signed = interceptor.sign_agent_card(_make_card())
405
+ resp = signed.to_well_known_response()
406
+ assert "_aevum_jws" in resp
407
+ assert "_aevum_signed_at" in resp
408
+
409
+ def test_to_well_known_response_has_card_fields(self) -> None:
410
+ kernel = _make_kernel()
411
+ interceptor = AevumA2AInterceptor(kernel)
412
+ signed = interceptor.sign_agent_card(_make_card())
413
+ resp = signed.to_well_known_response()
414
+ assert "name" in resp
415
+ assert resp["name"] == "TestAgent"
416
+
417
+ def test_to_well_known_response_no_kind_field(self) -> None:
418
+ kernel = _make_kernel()
419
+ interceptor = AevumA2AInterceptor(kernel)
420
+ signed = interceptor.sign_agent_card(_make_card())
421
+ resp = signed.to_well_known_response()
422
+ assert "kind" not in resp
423
+
424
+ def test_jws_no_padding_in_token(self) -> None:
425
+ kernel = _make_kernel()
426
+ interceptor = AevumA2AInterceptor(kernel)
427
+ signed = interceptor.sign_agent_card(_make_card())
428
+ assert "=" not in signed.jws_token
429
+
430
+
431
+ class TestB64url:
432
+ def test_roundtrip(self) -> None:
433
+ data = b"hello world"
434
+ assert _b64url_decode(_b64url(data)) == data
435
+
436
+ def test_no_padding_chars(self) -> None:
437
+ encoded = _b64url(b"test data")
438
+ assert "=" not in encoded
439
+
440
+ def test_url_safe_chars_only(self) -> None:
441
+ encoded = _b64url(b"\xff\xfe\xfd")
442
+ assert "+" not in encoded
443
+ assert "/" not in encoded
444
+
445
+ def test_empty_bytes(self) -> None:
446
+ assert _b64url(b"") == ""
447
+
448
+ def test_decode_empty(self) -> None:
449
+ assert _b64url_decode("") == b""
450
+
451
+ def test_roundtrip_binary(self) -> None:
452
+ data = bytes(range(256))
453
+ assert _b64url_decode(_b64url(data)) == data
454
+
455
+
456
+ class TestAevumAgentPackage:
457
+ def test_package_exports_task_status(self) -> None:
458
+ from aevum.agent import TaskStatus
459
+ assert TaskStatus is not None
460
+
461
+ def test_package_exports_a2a_task(self) -> None:
462
+ from aevum.agent import A2ATask
463
+ assert A2ATask is not None
464
+
465
+ def test_package_exports_agent_card(self) -> None:
466
+ from aevum.agent import AgentCard
467
+ assert AgentCard is not None
468
+
469
+ def test_package_exports_interceptor(self) -> None:
470
+ from aevum.agent import AevumA2AInterceptor
471
+ assert AevumA2AInterceptor is not None
472
+
473
+ def test_package_exports_signed_task(self) -> None:
474
+ from aevum.agent import SignedTask
475
+ assert SignedTask is not None
476
+
477
+ def test_package_exports_signed_agent_card(self) -> None:
478
+ from aevum.agent import SignedAgentCard
479
+ assert SignedAgentCard is not None
480
+
481
+ def test_package_version(self) -> None:
482
+ from aevum.agent import __version__
483
+ assert __version__ == "0.4.0"
484
+
485
+ def test_all_contains_expected_exports(self) -> None:
486
+ import aevum.agent
487
+ for name in (
488
+ "A2ATask", "AgentCard", "TaskStatus", "AgentCapability",
489
+ "AevumA2AInterceptor", "SignedTask", "SignedAgentCard",
490
+ ):
491
+ assert name in aevum.agent.__all__