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.
- aevum_agent-0.4.0/.gitignore +49 -0
- aevum_agent-0.4.0/PKG-INFO +59 -0
- aevum_agent-0.4.0/README.md +37 -0
- aevum_agent-0.4.0/pyproject.toml +59 -0
- aevum_agent-0.4.0/src/aevum/agent/__init__.py +32 -0
- aevum_agent-0.4.0/src/aevum/agent/interceptor.py +259 -0
- aevum_agent-0.4.0/src/aevum/agent/py.typed +0 -0
- aevum_agent-0.4.0/src/aevum/agent/types.py +108 -0
- aevum_agent-0.4.0/tests/test_phase6_a2a.py +491 -0
|
@@ -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__
|