treessera 0.1.0__py3-none-any.whl
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.
- treessera/__init__.py +102 -0
- treessera/card.py +59 -0
- treessera/client.py +242 -0
- treessera/config.py +28 -0
- treessera/evidence.py +56 -0
- treessera/identity.py +120 -0
- treessera/negotiation.py +60 -0
- treessera/protocol.py +97 -0
- treessera/provider.py +218 -0
- treessera/py.typed +0 -0
- treessera/registry.py +81 -0
- treessera/server.py +85 -0
- treessera-0.1.0.dist-info/METADATA +180 -0
- treessera-0.1.0.dist-info/RECORD +17 -0
- treessera-0.1.0.dist-info/WHEEL +4 -0
- treessera-0.1.0.dist-info/licenses/LICENSE +201 -0
- treessera-0.1.0.dist-info/licenses/NOTICE +10 -0
treessera/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tessera SDK — build and consume Verified, transactable A2A agents.
|
|
2
|
+
|
|
3
|
+
Two entry points cover almost everything:
|
|
4
|
+
|
|
5
|
+
* :class:`Agent` — the provider. Give it a capability, a price, and a work
|
|
6
|
+
function; it mints a persistent signed identity, publishes a card, negotiates,
|
|
7
|
+
proves itself under challenge, and self-registers::
|
|
8
|
+
|
|
9
|
+
from treessera import Agent
|
|
10
|
+
|
|
11
|
+
agent = Agent(name="Terraform Bot", description="Generates Terraform",
|
|
12
|
+
capability="terraform.generate", list_price=45, min_price=30,
|
|
13
|
+
registry_url="https://registry.example", keys_dir="./keys")
|
|
14
|
+
|
|
15
|
+
@agent.task
|
|
16
|
+
def handle(task):
|
|
17
|
+
return {"main.tf": generate(task.input)}
|
|
18
|
+
|
|
19
|
+
agent.run(port=8100)
|
|
20
|
+
|
|
21
|
+
* :class:`Client` — the requester. Discover, cryptographically verify, negotiate,
|
|
22
|
+
and delegate — peer to peer, never through the registry::
|
|
23
|
+
|
|
24
|
+
from treessera import Client
|
|
25
|
+
|
|
26
|
+
client = Client(registry_url="https://registry.example")
|
|
27
|
+
agent = client.discover("terraform.generate")[0]
|
|
28
|
+
result = client.delegate(agent, {"provider": "aws"}, max_price=40)
|
|
29
|
+
|
|
30
|
+
Everything below the two facades (Identity, Pricing, Evidence, the protocol
|
|
31
|
+
constants) is exported too, for advanced or clean-room use.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from treessera.card import build_card
|
|
35
|
+
from treessera.client import Client, ClientError, RemoteAgent
|
|
36
|
+
from treessera.config import (
|
|
37
|
+
A2A_PROTOCOL_VERSION,
|
|
38
|
+
PRINCIPAL_PREFIX,
|
|
39
|
+
SDK_VERSION,
|
|
40
|
+
TRUST_EXTENSION_URI,
|
|
41
|
+
WELL_KNOWN_CARD_PATH,
|
|
42
|
+
)
|
|
43
|
+
from treessera.evidence import Evidence, sha256_hex
|
|
44
|
+
from treessera.identity import (
|
|
45
|
+
Identity,
|
|
46
|
+
derive_principal_id,
|
|
47
|
+
principal_matches_pubkey,
|
|
48
|
+
verify_signature,
|
|
49
|
+
)
|
|
50
|
+
from treessera.negotiation import Pricing
|
|
51
|
+
from treessera.protocol import (
|
|
52
|
+
A2A_METHOD,
|
|
53
|
+
ProtocolError,
|
|
54
|
+
TASK_ACCEPT,
|
|
55
|
+
TASK_COUNTER,
|
|
56
|
+
TASK_OFFER,
|
|
57
|
+
TASK_REQUEST,
|
|
58
|
+
TASK_RESULT,
|
|
59
|
+
TRUST_CHALLENGE,
|
|
60
|
+
TRUST_PROOF,
|
|
61
|
+
)
|
|
62
|
+
from treessera.provider import Agent, Task
|
|
63
|
+
from treessera.server import AgentServer
|
|
64
|
+
|
|
65
|
+
__version__ = SDK_VERSION
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
# facades
|
|
69
|
+
"Agent",
|
|
70
|
+
"Task",
|
|
71
|
+
"Client",
|
|
72
|
+
"RemoteAgent",
|
|
73
|
+
"ClientError",
|
|
74
|
+
# identity + crypto
|
|
75
|
+
"Identity",
|
|
76
|
+
"derive_principal_id",
|
|
77
|
+
"principal_matches_pubkey",
|
|
78
|
+
"verify_signature",
|
|
79
|
+
# building blocks
|
|
80
|
+
"Pricing",
|
|
81
|
+
"Evidence",
|
|
82
|
+
"sha256_hex",
|
|
83
|
+
"build_card",
|
|
84
|
+
"AgentServer",
|
|
85
|
+
"ProtocolError",
|
|
86
|
+
# protocol vocabulary
|
|
87
|
+
"TASK_REQUEST",
|
|
88
|
+
"TASK_OFFER",
|
|
89
|
+
"TASK_COUNTER",
|
|
90
|
+
"TASK_ACCEPT",
|
|
91
|
+
"TASK_RESULT",
|
|
92
|
+
"TRUST_CHALLENGE",
|
|
93
|
+
"TRUST_PROOF",
|
|
94
|
+
"A2A_METHOD",
|
|
95
|
+
# constants
|
|
96
|
+
"TRUST_EXTENSION_URI",
|
|
97
|
+
"A2A_PROTOCOL_VERSION",
|
|
98
|
+
"SDK_VERSION",
|
|
99
|
+
"PRINCIPAL_PREFIX",
|
|
100
|
+
"WELL_KNOWN_CARD_PATH",
|
|
101
|
+
"__version__",
|
|
102
|
+
]
|
treessera/card.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The A2A agent card — the public identity + capability descriptor an agent
|
|
2
|
+
publishes at ``/.well-known/agent-card.json``.
|
|
3
|
+
|
|
4
|
+
The card is what makes an agent discoverable and callable. Declaring the trust
|
|
5
|
+
extension (with the agent's ``principal_id``) is what makes it eligible for the
|
|
6
|
+
Verified tier; the SDK always includes it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from treessera.config import A2A_PROTOCOL_VERSION, TRUST_EXTENSION_URI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_card(name, description, url, principal_id, skills,
|
|
13
|
+
version="0.1.0", verification_url=None, requires_auth=False):
|
|
14
|
+
# type: (str, str, str, str, list, str, str, bool) -> dict
|
|
15
|
+
"""Build the A2A card. ``skills`` is a list of ``{"id", "name"?,
|
|
16
|
+
"description"?, "tags"?}`` — the ``id`` is the capability the agent offers
|
|
17
|
+
(dot-namespaced), and the registry indexes discovery off it."""
|
|
18
|
+
trust_params = {"principal_id": principal_id}
|
|
19
|
+
if verification_url:
|
|
20
|
+
trust_params["verification_service_url"] = verification_url
|
|
21
|
+
|
|
22
|
+
card = {
|
|
23
|
+
"name": name,
|
|
24
|
+
"description": description,
|
|
25
|
+
"url": url,
|
|
26
|
+
"version": version,
|
|
27
|
+
"protocolVersion": A2A_PROTOCOL_VERSION,
|
|
28
|
+
"preferredTransport": "JSONRPC",
|
|
29
|
+
"capabilities": {
|
|
30
|
+
"streaming": False,
|
|
31
|
+
"pushNotifications": False,
|
|
32
|
+
"extensions": [
|
|
33
|
+
{
|
|
34
|
+
"uri": TRUST_EXTENSION_URI,
|
|
35
|
+
"description": "Tessera: signed identity, negotiated "
|
|
36
|
+
"work, machine-checkable evidence, independent "
|
|
37
|
+
"verification, and provable per-capability reputation.",
|
|
38
|
+
"required": False,
|
|
39
|
+
"params": trust_params,
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
"defaultInputModes": ["application/json"],
|
|
44
|
+
"defaultOutputModes": ["application/json"],
|
|
45
|
+
"skills": [_normalize_skill(s) for s in skills],
|
|
46
|
+
}
|
|
47
|
+
if requires_auth:
|
|
48
|
+
card["securitySchemes"] = {"bearer": {"type": "http", "scheme": "bearer"}}
|
|
49
|
+
card["security"] = [{"bearer": []}]
|
|
50
|
+
return card
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_skill(skill):
|
|
54
|
+
# type: (object) -> dict
|
|
55
|
+
if isinstance(skill, str):
|
|
56
|
+
return {"id": skill, "name": skill}
|
|
57
|
+
s = dict(skill)
|
|
58
|
+
s.setdefault("name", s.get("id", ""))
|
|
59
|
+
return s
|
treessera/client.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""The requester side — discover, verify, negotiate, and delegate work.
|
|
2
|
+
|
|
3
|
+
A requester never proxies through the registry: it looks up candidates, calls
|
|
4
|
+
each agent *directly* at its card ``url``, proves the agent's identity with a
|
|
5
|
+
signature challenge, negotiates a price, and delegates the task. This is the
|
|
6
|
+
peer-to-peer half of Tessera.
|
|
7
|
+
|
|
8
|
+
from treessera import Client
|
|
9
|
+
|
|
10
|
+
client = Client(registry_url="https://registry.example")
|
|
11
|
+
agent = client.discover("terraform.generate")[0] # a RemoteAgent
|
|
12
|
+
if client.verify(agent): # cryptographic proof
|
|
13
|
+
result = client.delegate(agent, {"provider": "aws"}, max_price=40)
|
|
14
|
+
print(result["output"])
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
from treessera import registry as _registry
|
|
24
|
+
from treessera.config import TRUST_EXTENSION_URI, WELL_KNOWN_CARD_PATH
|
|
25
|
+
from treessera.identity import principal_matches_pubkey, verify_signature
|
|
26
|
+
from treessera.protocol import (
|
|
27
|
+
TASK_ACCEPT,
|
|
28
|
+
TASK_COUNTER,
|
|
29
|
+
TASK_OFFER,
|
|
30
|
+
TASK_REQUEST,
|
|
31
|
+
TASK_RESULT,
|
|
32
|
+
TRUST_CHALLENGE,
|
|
33
|
+
TRUST_PROOF,
|
|
34
|
+
payload_of_result,
|
|
35
|
+
wrap_request,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_HEADERS = {
|
|
39
|
+
"User-Agent": "treessera-sdk/0.1",
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ClientError(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RemoteAgent(object):
|
|
50
|
+
"""A discovered agent: its card plus the endpoints derived from it. Kept
|
|
51
|
+
small and serializable so a requester can cache or pass it around."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, card, reputation=None):
|
|
54
|
+
# type: (dict, dict) -> None
|
|
55
|
+
self.card = card or {}
|
|
56
|
+
self.name = self.card.get("name", "")
|
|
57
|
+
self.reputation = reputation or {}
|
|
58
|
+
self.endpoint_url = self.card.get("url") or ""
|
|
59
|
+
self.principal_id = _principal_of(self.card)
|
|
60
|
+
self.verified = False # flips true once verify() proves the identity
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def declares_trust(self):
|
|
64
|
+
# type: () -> bool
|
|
65
|
+
return bool(self.principal_id)
|
|
66
|
+
|
|
67
|
+
def capabilities(self):
|
|
68
|
+
# type: () -> list
|
|
69
|
+
return [s.get("id") for s in self.card.get("skills") or [] if s.get("id")]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Client(object):
|
|
73
|
+
def __init__(self, registry_url=None, auth_token=None, timeout=10.0):
|
|
74
|
+
# type: (str, str, float) -> None
|
|
75
|
+
self.registry_url = registry_url
|
|
76
|
+
self.auth_token = auth_token
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
|
|
79
|
+
# -- discovery ------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def discover(self, capability, min_reputation=None):
|
|
82
|
+
# type: (str, float) -> list
|
|
83
|
+
"""Find agents offering ``capability`` via the registry. Returns
|
|
84
|
+
RemoteAgents, best-reputation first (order as the registry ranks them)."""
|
|
85
|
+
if not self.registry_url:
|
|
86
|
+
raise ClientError("no registry_url configured for discovery")
|
|
87
|
+
candidates = _registry.search(self.registry_url, capability, min_reputation, self.timeout)
|
|
88
|
+
return [RemoteAgent(c.get("agent_card"), c.get("reputation_summary")) for c in candidates]
|
|
89
|
+
|
|
90
|
+
def fetch_card(self, url):
|
|
91
|
+
# type: (str) -> RemoteAgent
|
|
92
|
+
"""Fetch an agent card directly by URL (base or full well-known URL) —
|
|
93
|
+
for connecting to an agent you already know, no registry involved."""
|
|
94
|
+
card = _get_json(_card_url(url), self.timeout)
|
|
95
|
+
return RemoteAgent(card)
|
|
96
|
+
|
|
97
|
+
# -- verification (the cryptographic proof) -------------------------------
|
|
98
|
+
|
|
99
|
+
def verify(self, agent, nonce=None):
|
|
100
|
+
# type: (RemoteAgent, str) -> bool
|
|
101
|
+
"""Prove the agent holds the private key behind its ``principal_id``.
|
|
102
|
+
|
|
103
|
+
Sends a random nonce as a ``trust.challenge`` and checks the returned
|
|
104
|
+
``trust.proof``: (a) the public key hashes to the declared principal_id
|
|
105
|
+
(binding) AND (b) the signature over the nonce validates. A copied
|
|
106
|
+
principal_id fails — the attacker cannot sign for a key it doesn't hold.
|
|
107
|
+
Never raises; returns False on any failure. Sets ``agent.verified``.
|
|
108
|
+
"""
|
|
109
|
+
if not agent.declares_trust:
|
|
110
|
+
return False
|
|
111
|
+
nonce = nonce or _nonce()
|
|
112
|
+
try:
|
|
113
|
+
envelope = self._call(agent, {"type": TRUST_CHALLENGE, "nonce": nonce})
|
|
114
|
+
except ClientError:
|
|
115
|
+
return False
|
|
116
|
+
proof = payload_of_result(envelope)
|
|
117
|
+
if proof.get("type") != TRUST_PROOF:
|
|
118
|
+
return False
|
|
119
|
+
public_key = proof.get("public_key")
|
|
120
|
+
signature = proof.get("signature")
|
|
121
|
+
if not isinstance(public_key, str) or not isinstance(signature, str):
|
|
122
|
+
return False
|
|
123
|
+
bound = principal_matches_pubkey(agent.principal_id, public_key)
|
|
124
|
+
signed = verify_signature(public_key, nonce.encode("utf-8"), signature)
|
|
125
|
+
agent.verified = bool(bound and signed)
|
|
126
|
+
return agent.verified
|
|
127
|
+
|
|
128
|
+
# -- negotiation + delegation ---------------------------------------------
|
|
129
|
+
|
|
130
|
+
def negotiate(self, agent, task_input, max_price=None, counter_price=None):
|
|
131
|
+
# type: (RemoteAgent, dict, float, float) -> dict
|
|
132
|
+
"""Run the negotiation and return an accepted offer dict
|
|
133
|
+
(``{task_id, price, ...}``), or raise ClientError if no acceptable
|
|
134
|
+
price is reached.
|
|
135
|
+
|
|
136
|
+
Sends ``task.request`` -> receives ``task.offer``. If the offer exceeds
|
|
137
|
+
``max_price`` and a ``counter_price`` is given, sends one
|
|
138
|
+
``task.counter``; a single counter round is the whole surface. The final
|
|
139
|
+
price must be <= ``max_price`` (when set) or the deal is declined."""
|
|
140
|
+
offer = self._expect(
|
|
141
|
+
self._call(agent, {"type": TASK_REQUEST, "input": task_input}),
|
|
142
|
+
TASK_OFFER, "task.offer",
|
|
143
|
+
)
|
|
144
|
+
if max_price is not None and offer.get("price", 0) > max_price:
|
|
145
|
+
proposed = counter_price if counter_price is not None else max_price
|
|
146
|
+
offer = self._expect(
|
|
147
|
+
self._call(agent, {
|
|
148
|
+
"type": TASK_COUNTER,
|
|
149
|
+
"task_id": offer.get("task_id"),
|
|
150
|
+
"proposed_price": proposed,
|
|
151
|
+
}),
|
|
152
|
+
TASK_OFFER, "task.offer (counter response)",
|
|
153
|
+
)
|
|
154
|
+
if max_price is not None and offer.get("price", 0) > max_price:
|
|
155
|
+
raise ClientError(
|
|
156
|
+
"no acceptable price: agent held %s %s, budget was %s"
|
|
157
|
+
% (offer.get("price"), offer.get("currency", ""), max_price))
|
|
158
|
+
return offer
|
|
159
|
+
|
|
160
|
+
def accept(self, agent, offer):
|
|
161
|
+
# type: (RemoteAgent, dict) -> dict
|
|
162
|
+
"""Accept an offer and collect the result payload (includes evidence)."""
|
|
163
|
+
return self._expect(
|
|
164
|
+
self._call(agent, {"type": TASK_ACCEPT, "task_id": offer.get("task_id")}),
|
|
165
|
+
TASK_RESULT, "task.result",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def delegate(self, agent, task_input, max_price=None, counter_price=None, require_verified=True):
|
|
169
|
+
# type: (RemoteAgent, dict, float, float, bool) -> dict
|
|
170
|
+
"""The whole happy path: (verify) -> negotiate -> accept. Returns the
|
|
171
|
+
result payload. By default refuses to transact with an unverified agent
|
|
172
|
+
— the Verified tier is what makes a result trustworthy."""
|
|
173
|
+
if require_verified and not agent.verified and not self.verify(agent):
|
|
174
|
+
raise ClientError(
|
|
175
|
+
"agent %r is not Verified — refusing to transact "
|
|
176
|
+
"(pass require_verified=False to override)" % (agent.name,))
|
|
177
|
+
offer = self.negotiate(agent, task_input, max_price, counter_price)
|
|
178
|
+
return self.accept(agent, offer)
|
|
179
|
+
|
|
180
|
+
# -- transport ------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _call(self, agent, payload):
|
|
183
|
+
# type: (RemoteAgent, dict) -> dict
|
|
184
|
+
if not agent.endpoint_url:
|
|
185
|
+
raise ClientError("agent %r has no callable url" % (agent.name,))
|
|
186
|
+
request = wrap_request(payload)
|
|
187
|
+
headers = dict(_HEADERS)
|
|
188
|
+
headers["X-A2A-Extensions"] = TRUST_EXTENSION_URI
|
|
189
|
+
if self.auth_token:
|
|
190
|
+
headers["Authorization"] = "Bearer " + self.auth_token
|
|
191
|
+
data = json.dumps(request).encode("utf-8")
|
|
192
|
+
req = urllib.request.Request(agent.endpoint_url, data=data, headers=headers, method="POST")
|
|
193
|
+
try:
|
|
194
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
195
|
+
envelope = json.loads(resp.read().decode("utf-8"))
|
|
196
|
+
except urllib.error.HTTPError as exc:
|
|
197
|
+
raise ClientError("agent returned HTTP %s calling %s" % (exc.code, agent.endpoint_url))
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
raise ClientError("could not reach agent at %s: %s" % (agent.endpoint_url, exc))
|
|
200
|
+
if isinstance(envelope, dict) and envelope.get("error"):
|
|
201
|
+
err = envelope["error"]
|
|
202
|
+
raise ClientError("agent error %s: %s" % (err.get("code"), err.get("message")))
|
|
203
|
+
return envelope
|
|
204
|
+
|
|
205
|
+
def _expect(self, envelope, ptype, label):
|
|
206
|
+
# type: (dict, str, str) -> dict
|
|
207
|
+
payload = payload_of_result(envelope)
|
|
208
|
+
if payload.get("type") != ptype:
|
|
209
|
+
raise ClientError("expected %s, got %r" % (label, payload.get("type")))
|
|
210
|
+
return payload
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _principal_of(card):
|
|
214
|
+
# type: (dict) -> str
|
|
215
|
+
for ext in ((card or {}).get("capabilities") or {}).get("extensions") or []:
|
|
216
|
+
if isinstance(ext, dict) and ext.get("uri") == TRUST_EXTENSION_URI:
|
|
217
|
+
return (ext.get("params") or {}).get("principal_id") or ""
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _card_url(url):
|
|
222
|
+
# type: (str) -> str
|
|
223
|
+
url = url.rstrip("/")
|
|
224
|
+
if url.endswith(WELL_KNOWN_CARD_PATH):
|
|
225
|
+
return url
|
|
226
|
+
return url + WELL_KNOWN_CARD_PATH
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _get_json(url, timeout):
|
|
230
|
+
# type: (str, float) -> dict
|
|
231
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json",
|
|
232
|
+
"User-Agent": _HEADERS["User-Agent"]})
|
|
233
|
+
try:
|
|
234
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
235
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
raise ClientError("could not fetch agent card at %s: %s" % (url, exc))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _nonce():
|
|
241
|
+
# type: () -> str
|
|
242
|
+
return base64.urlsafe_b64encode(os.urandom(24)).decode("ascii").rstrip("=")
|
treessera/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Protocol + brand constants — the ONE place the Tessera identity lives.
|
|
2
|
+
|
|
3
|
+
The trust-extension URI is a namespace identifier (compared as an exact
|
|
4
|
+
string), not a URL that gets fetched. It must match on both sides — an agent
|
|
5
|
+
declares it on its card, and the registry/verification service compare against
|
|
6
|
+
it — so change it here and everything (cards, verification, discovery) follows.
|
|
7
|
+
It can also be overridden per-process via the ``TREESSERA_URI`` env var without
|
|
8
|
+
touching code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
#: The A2A extension URI that marks an agent as a Tessera participant.
|
|
14
|
+
TRUST_EXTENSION_URI = os.environ.get(
|
|
15
|
+
"TREESSERA_URI", "https://treessera.com/extensions/trust/v1"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
#: A2A protocol version this SDK speaks.
|
|
19
|
+
A2A_PROTOCOL_VERSION = "0.3.0"
|
|
20
|
+
|
|
21
|
+
#: This SDK's version.
|
|
22
|
+
SDK_VERSION = "0.1.0"
|
|
23
|
+
|
|
24
|
+
#: Prefix for Tessera principal identifiers.
|
|
25
|
+
PRINCIPAL_PREFIX = "atp:principal:"
|
|
26
|
+
|
|
27
|
+
#: Where an agent publishes its card and where verifiers fetch it.
|
|
28
|
+
WELL_KNOWN_CARD_PATH = "/.well-known/agent-card.json"
|
treessera/evidence.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Evidence — the machine-checkable proof that a task result is real.
|
|
2
|
+
|
|
3
|
+
A result carries evidence (artifact hashes, self-checks) so an independent
|
|
4
|
+
verification service can confirm it before it counts toward reputation. This is
|
|
5
|
+
what makes Tessera reputation *provable* rather than self-reported.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sha256_hex(data):
|
|
14
|
+
# type: (object) -> str
|
|
15
|
+
if isinstance(data, str):
|
|
16
|
+
data = data.encode("utf-8")
|
|
17
|
+
elif not isinstance(data, (bytes, bytearray)):
|
|
18
|
+
data = str(data).encode("utf-8")
|
|
19
|
+
return hashlib.sha256(data).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Evidence(object):
|
|
23
|
+
"""Builds the evidence record for a task result.
|
|
24
|
+
|
|
25
|
+
``artifacts`` maps a filename to its content; each is hashed. ``schema_valid``
|
|
26
|
+
and ``tests_passed`` are the agent's self-checks — the verifier re-runs its
|
|
27
|
+
own, but the agent asserts what it believes.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, capability_id, session_id=None):
|
|
31
|
+
# type: (str, str) -> None
|
|
32
|
+
self.capability_id = capability_id
|
|
33
|
+
self.session_id = session_id or "sess-%s" % uuid.uuid4().hex
|
|
34
|
+
self._artifacts = {} # filename -> content
|
|
35
|
+
self.schema_valid = True
|
|
36
|
+
self.tests_passed = True
|
|
37
|
+
|
|
38
|
+
def add_artifact(self, filename, content):
|
|
39
|
+
# type: (str, object) -> Evidence
|
|
40
|
+
self._artifacts[filename] = content
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def artifacts(self):
|
|
44
|
+
# type: () -> dict
|
|
45
|
+
return dict(self._artifacts)
|
|
46
|
+
|
|
47
|
+
def to_dict(self):
|
|
48
|
+
# type: () -> dict
|
|
49
|
+
return {
|
|
50
|
+
"evidence_id": "ev-%s" % uuid.uuid4().hex,
|
|
51
|
+
"session_id": self.session_id,
|
|
52
|
+
"capability_id": self.capability_id,
|
|
53
|
+
"schema_valid": bool(self.schema_valid),
|
|
54
|
+
"tests_passed": bool(self.tests_passed),
|
|
55
|
+
"artifact_hashes": {name: sha256_hex(c) for name, c in self._artifacts.items()},
|
|
56
|
+
}
|
treessera/identity.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Agent identity — the ed25519 keypair that makes an agent *provable*.
|
|
2
|
+
|
|
3
|
+
The private key never leaves the process; only the public key and a derived
|
|
4
|
+
``principal_id`` are ever shared. The id is cryptographically bound to the key
|
|
5
|
+
(``atp:principal:<label>:sha256(b64_pubkey)[:16]``) so a verifier can confirm a
|
|
6
|
+
presented public key really belongs to a claimed id — this is what the
|
|
7
|
+
signature challenge relies on.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
import nacl.exceptions
|
|
16
|
+
import nacl.signing
|
|
17
|
+
|
|
18
|
+
from treessera.config import PRINCIPAL_PREFIX
|
|
19
|
+
|
|
20
|
+
_KEY_FILENAME = "principal.key"
|
|
21
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _b64(raw):
|
|
25
|
+
# type: (bytes) -> str
|
|
26
|
+
return base64.b64encode(raw).decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _slug(label):
|
|
30
|
+
# type: (str) -> str
|
|
31
|
+
s = _SLUG_RE.sub("-", (label or "agent").lower()).strip("-")
|
|
32
|
+
return s or "agent"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def derive_principal_id(public_key_b64, label="agent"):
|
|
36
|
+
# type: (str, str) -> str
|
|
37
|
+
"""Bind a principal id to a public key. The label is cosmetic; only the
|
|
38
|
+
trailing hash is verified, so the id cannot be forged for a key you do not
|
|
39
|
+
hold."""
|
|
40
|
+
digest = hashlib.sha256(public_key_b64.encode("ascii")).hexdigest()[:16]
|
|
41
|
+
return "%s%s:%s" % (PRINCIPAL_PREFIX, _slug(label), digest)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def principal_matches_pubkey(principal_id, public_key_b64):
|
|
45
|
+
# type: (str, str) -> bool
|
|
46
|
+
"""True iff ``public_key_b64`` is the key ``principal_id`` was derived
|
|
47
|
+
from. Supports the raw-pubkey form (``principal_id`` == the base64 key)
|
|
48
|
+
for compatibility with user principals."""
|
|
49
|
+
if not isinstance(principal_id, str) or not isinstance(public_key_b64, str):
|
|
50
|
+
return False
|
|
51
|
+
digest = hashlib.sha256(public_key_b64.encode("ascii")).hexdigest()[:16]
|
|
52
|
+
return principal_id.endswith(":" + digest) or principal_id == public_key_b64
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Identity(object):
|
|
56
|
+
"""An agent's ed25519 identity, loaded from — or created once in — a
|
|
57
|
+
directory. Reuse the same ``keys_dir`` across restarts to keep a stable
|
|
58
|
+
``principal_id`` (and therefore stable reputation)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, signing_key, label="agent"):
|
|
61
|
+
# type: (nacl.signing.SigningKey, str) -> None
|
|
62
|
+
self._sk = signing_key
|
|
63
|
+
self.public_key_b64 = _b64(bytes(signing_key.verify_key))
|
|
64
|
+
self.principal_id = derive_principal_id(self.public_key_b64, label)
|
|
65
|
+
|
|
66
|
+
# -- construction ---------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def generate(cls, label="agent"):
|
|
70
|
+
# type: (str) -> Identity
|
|
71
|
+
return cls(nacl.signing.SigningKey.generate(), label)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def load_or_create(cls, keys_dir, label="agent"):
|
|
75
|
+
# type: (str, str) -> Identity
|
|
76
|
+
"""Load the key from ``keys_dir`` if present, else create it once. The
|
|
77
|
+
directory gets a ``.gitignore`` so the private key can never be
|
|
78
|
+
committed."""
|
|
79
|
+
os.makedirs(keys_dir, exist_ok=True)
|
|
80
|
+
gitignore = os.path.join(keys_dir, ".gitignore")
|
|
81
|
+
if not os.path.exists(gitignore):
|
|
82
|
+
with open(gitignore, "w") as fh:
|
|
83
|
+
fh.write("*\n")
|
|
84
|
+
path = os.path.join(keys_dir, _KEY_FILENAME)
|
|
85
|
+
if os.path.exists(path):
|
|
86
|
+
with open(path, "rb") as fh:
|
|
87
|
+
seed = base64.b64decode(fh.read().strip())
|
|
88
|
+
return cls(nacl.signing.SigningKey(seed), label)
|
|
89
|
+
sk = nacl.signing.SigningKey.generate()
|
|
90
|
+
with open(path, "wb") as fh:
|
|
91
|
+
fh.write(base64.b64encode(bytes(sk)))
|
|
92
|
+
try:
|
|
93
|
+
os.chmod(path, 0o600)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
return cls(sk, label)
|
|
97
|
+
|
|
98
|
+
# -- signing --------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def sign(self, message):
|
|
101
|
+
# type: (bytes) -> str
|
|
102
|
+
"""Return the base64 signature over ``message``."""
|
|
103
|
+
return _b64(self._sk.sign(message).signature)
|
|
104
|
+
|
|
105
|
+
def sign_text(self, text):
|
|
106
|
+
# type: (str) -> str
|
|
107
|
+
return self.sign(text.encode("utf-8"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def verify_signature(public_key_b64, message, signature_b64):
|
|
111
|
+
# type: (str, bytes, str) -> bool
|
|
112
|
+
"""Verify an ed25519 signature; never raises — returns False on any
|
|
113
|
+
problem."""
|
|
114
|
+
try:
|
|
115
|
+
vk = nacl.signing.VerifyKey(base64.b64decode(public_key_b64))
|
|
116
|
+
vk.verify(message, base64.b64decode(signature_b64))
|
|
117
|
+
return True
|
|
118
|
+
except (ValueError, TypeError, nacl.exceptions.BadSignatureError,
|
|
119
|
+
nacl.exceptions.CryptoError):
|
|
120
|
+
return False
|
treessera/negotiation.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Price negotiation — the offer/counter logic, with a PRIVATE reserve.
|
|
2
|
+
|
|
3
|
+
The reserve (``min_price``) is never serialized: it appears in no card, no
|
|
4
|
+
offer, and no counter response. A counter at or above the reserve is accepted at
|
|
5
|
+
the countered price; below it, the agent holds its list price. This is the whole
|
|
6
|
+
negotiation surface — single-round by design (RFC-0001/KTD6).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from treessera.protocol import TASK_OFFER
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Pricing(object):
|
|
13
|
+
def __init__(self, list_price, min_price=None, currency="USD", delivery="immediate"):
|
|
14
|
+
# type: (float, float, str, str) -> None
|
|
15
|
+
if list_price is None:
|
|
16
|
+
raise ValueError("list_price is required")
|
|
17
|
+
self.list_price = float(list_price)
|
|
18
|
+
# reserve defaults to list price (no discounting) and is clamped so it
|
|
19
|
+
# can never exceed the public price
|
|
20
|
+
reserve = self.list_price if min_price is None else float(min_price)
|
|
21
|
+
self.min_price = min(reserve, self.list_price)
|
|
22
|
+
self.currency = currency
|
|
23
|
+
self.delivery = delivery
|
|
24
|
+
|
|
25
|
+
def offer(self, task_id, capability_id, terms=None):
|
|
26
|
+
# type: (str, str, dict) -> dict
|
|
27
|
+
"""The public opening offer. Only the list price crosses the wire."""
|
|
28
|
+
payload = {
|
|
29
|
+
"type": TASK_OFFER,
|
|
30
|
+
"task_id": task_id,
|
|
31
|
+
"capability_id": capability_id,
|
|
32
|
+
"price": self.list_price,
|
|
33
|
+
"currency": self.currency,
|
|
34
|
+
"delivery": self.delivery,
|
|
35
|
+
}
|
|
36
|
+
if terms:
|
|
37
|
+
payload["terms"] = terms
|
|
38
|
+
return payload
|
|
39
|
+
|
|
40
|
+
def respond_to_counter(self, task_id, capability_id, proposed_price, terms=None):
|
|
41
|
+
# type: (str, str, float, dict) -> dict
|
|
42
|
+
"""Evaluate a counter-offer. At/above the private reserve -> re-offer at
|
|
43
|
+
the countered price (a deal). Below it -> hold the list price. Either way
|
|
44
|
+
the reserve itself is never revealed."""
|
|
45
|
+
try:
|
|
46
|
+
proposed = float(proposed_price)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
proposed = None
|
|
49
|
+
price = proposed if (proposed is not None and proposed >= self.min_price) else self.list_price
|
|
50
|
+
payload = {
|
|
51
|
+
"type": TASK_OFFER,
|
|
52
|
+
"task_id": task_id,
|
|
53
|
+
"capability_id": capability_id,
|
|
54
|
+
"price": price,
|
|
55
|
+
"currency": self.currency,
|
|
56
|
+
"delivery": self.delivery,
|
|
57
|
+
}
|
|
58
|
+
if terms:
|
|
59
|
+
payload["terms"] = terms
|
|
60
|
+
return payload
|