@agentlayer.tech/wallet 0.1.101 → 0.1.102
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.
- package/.openclaw/extensions/agent-wallet/dist/index.js +46 -1
- package/.openclaw/extensions/agent-wallet/index.ts +46 -1
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/README.md +18 -0
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/connector_cli.py +198 -0
- package/agent-wallet/agent_wallet/connectors/__init__.py +28 -0
- package/agent-wallet/agent_wallet/connectors/catalog.py +68 -0
- package/agent-wallet/agent_wallet/connectors/client.py +308 -0
- package/agent-wallet/agent_wallet/connectors/intent_policy.py +243 -0
- package/agent-wallet/agent_wallet/connectors/manifest.py +192 -0
- package/agent-wallet/agent_wallet/connectors/registry.py +341 -0
- package/agent-wallet/agent_wallet/openclaw_adapter.py +56 -5
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +4 -2
- package/agent-wallet/scripts/build_release_bundle.py +1 -0
- package/agent-wallet/scripts/install_agent_wallet.py +1 -1
- package/bin/openclaw-agent-wallet.mjs +38 -0
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/server.py +4 -1
- package/connectors/AGENTS.md +37 -0
- package/connectors/DEVELOPER_GUIDE.md +131 -0
- package/connectors/README.md +139 -0
- package/connectors/READ_ONLY_BETA.md +54 -0
- package/connectors/RELEASING.md +48 -0
- package/connectors/conformance/README.md +31 -0
- package/connectors/conformance/package.json +54 -0
- package/connectors/conformance/scripts/sync-license.mjs +3 -0
- package/connectors/conformance/scripts/sync-spec.mjs +16 -0
- package/connectors/conformance/src/cli.ts +43 -0
- package/connectors/conformance/src/index.ts +7 -0
- package/connectors/conformance/src/runner.ts +314 -0
- package/connectors/conformance/src/types.ts +27 -0
- package/connectors/conformance/test/fixtures/invalid-response-identity.json +14 -0
- package/connectors/conformance/test/fixtures/missing-tool-fixture.json +4 -0
- package/connectors/conformance/test/runner.test.mjs +151 -0
- package/connectors/conformance/tsconfig.json +17 -0
- package/connectors/examples/reference-crypto-data/Dockerfile +21 -0
- package/connectors/examples/reference-crypto-data/README.md +51 -0
- package/connectors/examples/reference-crypto-data/conformance.json +11 -0
- package/connectors/examples/reference-crypto-data/connector.json +95 -0
- package/connectors/examples/reference-crypto-data/package.json +21 -0
- package/connectors/examples/reference-crypto-data/railway.toml +9 -0
- package/connectors/examples/reference-crypto-data/src/connector.ts +137 -0
- package/connectors/examples/reference-crypto-data/src/server.ts +13 -0
- package/connectors/examples/reference-crypto-data/test/connector.test.mjs +97 -0
- package/connectors/examples/reference-crypto-data/tsconfig.json +17 -0
- package/connectors/package-lock.json +561 -0
- package/connectors/package.json +19 -0
- package/connectors/scripts/smoke-packed-packages.mjs +121 -0
- package/connectors/sdk-typescript/README.md +47 -0
- package/connectors/sdk-typescript/package.json +49 -0
- package/connectors/sdk-typescript/scripts/sync-license.mjs +3 -0
- package/connectors/sdk-typescript/src/connector.ts +204 -0
- package/connectors/sdk-typescript/src/errors.ts +11 -0
- package/connectors/sdk-typescript/src/http.ts +116 -0
- package/connectors/sdk-typescript/src/index.ts +26 -0
- package/connectors/sdk-typescript/src/types.ts +101 -0
- package/connectors/sdk-typescript/test/sdk.test.mjs +181 -0
- package/connectors/sdk-typescript/tsconfig.json +21 -0
- package/connectors/spec/connector-manifest.schema.json +340 -0
- package/connectors/spec/connector-protocol.md +159 -0
- package/connectors/spec/transaction-intent.schema.json +281 -0
- package/connectors/templates/read-only/.env.example +1 -0
- package/connectors/templates/read-only/Dockerfile +11 -0
- package/connectors/templates/read-only/README.md +33 -0
- package/connectors/templates/read-only/conformance.json +11 -0
- package/connectors/templates/read-only/connector.json +64 -0
- package/connectors/templates/read-only/package.json +21 -0
- package/connectors/templates/read-only/railway.toml +9 -0
- package/connectors/templates/read-only/src/connector.ts +61 -0
- package/connectors/templates/read-only/src/server.ts +14 -0
- package/connectors/templates/read-only/test/connector.test.mjs +62 -0
- package/connectors/templates/read-only/tsconfig.json +17 -0
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +2 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""HTTPS client for untrusted read-only connector endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import socket
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Callable, Iterable
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import httpcore
|
|
15
|
+
from jsonschema import Draft202012Validator, SchemaError, ValidationError
|
|
16
|
+
|
|
17
|
+
from agent_wallet.connectors.catalog import resolve_connector_tool
|
|
18
|
+
from agent_wallet.connectors.registry import ConnectorRegistry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MAX_CONNECTOR_RESPONSE_BYTES = 1024 * 1024
|
|
22
|
+
MAX_RESPONSE_LIFETIME_SECONDS = 300
|
|
23
|
+
RESERVED_WRITE_RESULT_KEYS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"approval_token",
|
|
26
|
+
"broadcast_request",
|
|
27
|
+
"evm_transaction_intent",
|
|
28
|
+
"payment_intent",
|
|
29
|
+
"raw_transaction",
|
|
30
|
+
"signed_transaction",
|
|
31
|
+
"signing_request",
|
|
32
|
+
"solana_transaction_intent",
|
|
33
|
+
"transaction_intent",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
Resolver = Callable[[str], Iterable[str]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConnectorInvocationError(RuntimeError):
|
|
40
|
+
"""Raised when a connector request or response cannot be trusted."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_resolver(hostname: str) -> list[str]:
|
|
44
|
+
try:
|
|
45
|
+
records = socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM)
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
raise ConnectorInvocationError(f"Connector hostname could not be resolved: {hostname}.") from exc
|
|
48
|
+
return sorted({str(record[4][0]) for record in records})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validate_endpoint_url(url: str) -> str:
|
|
52
|
+
parsed = urlparse(url)
|
|
53
|
+
hostname = str(parsed.hostname or "").lower()
|
|
54
|
+
if parsed.scheme != "https" or not hostname or parsed.username or parsed.password:
|
|
55
|
+
raise ConnectorInvocationError("Connector endpoint must be public HTTPS without credentials.")
|
|
56
|
+
if hostname == "localhost" or hostname.endswith(".localhost") or hostname.endswith(".local"):
|
|
57
|
+
raise ConnectorInvocationError("Connector endpoint cannot use a local hostname.")
|
|
58
|
+
return hostname
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_public_addresses(hostname: str, resolver: Resolver) -> list[str]:
|
|
62
|
+
addresses = list(resolver(hostname))
|
|
63
|
+
if not addresses:
|
|
64
|
+
raise ConnectorInvocationError("Connector endpoint did not resolve to an address.")
|
|
65
|
+
for raw_address in addresses:
|
|
66
|
+
try:
|
|
67
|
+
address = ipaddress.ip_address(raw_address)
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise ConnectorInvocationError("Connector endpoint resolved to an invalid address.") from exc
|
|
70
|
+
if not address.is_global:
|
|
71
|
+
raise ConnectorInvocationError(
|
|
72
|
+
f"Connector endpoint resolved to a non-public address: {raw_address}."
|
|
73
|
+
)
|
|
74
|
+
return addresses
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _assert_public_endpoint(url: str, resolver: Resolver) -> None:
|
|
78
|
+
"""Validate mocked/injected clients that do not use the pinned transport."""
|
|
79
|
+
|
|
80
|
+
_resolve_public_addresses(_validate_endpoint_url(url), resolver)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _PinnedPublicNetworkBackend(httpcore.AsyncNetworkBackend):
|
|
84
|
+
"""Resolve and validate at connect time, then open TCP to the validated IP.
|
|
85
|
+
|
|
86
|
+
HTTP Core retains the original origin hostname and therefore still uses it
|
|
87
|
+
for TLS SNI and certificate verification. Only the TCP destination is
|
|
88
|
+
replaced, closing the DNS-validation-to-connect race.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
resolver: Resolver,
|
|
94
|
+
network_backend: httpcore.AsyncNetworkBackend | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._resolver = resolver
|
|
97
|
+
self._network_backend = network_backend or httpcore.AnyIOBackend()
|
|
98
|
+
|
|
99
|
+
async def connect_tcp(
|
|
100
|
+
self,
|
|
101
|
+
host: str,
|
|
102
|
+
port: int,
|
|
103
|
+
timeout: float | None = None,
|
|
104
|
+
local_address: str | None = None,
|
|
105
|
+
socket_options: Iterable[tuple[int, int, int | bytes]] | None = None,
|
|
106
|
+
) -> httpcore.AsyncNetworkStream:
|
|
107
|
+
addresses = _resolve_public_addresses(host.lower(), self._resolver)
|
|
108
|
+
return await self._network_backend.connect_tcp(
|
|
109
|
+
addresses[0],
|
|
110
|
+
port,
|
|
111
|
+
timeout=timeout,
|
|
112
|
+
local_address=local_address,
|
|
113
|
+
socket_options=socket_options,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def connect_unix_socket(
|
|
117
|
+
self,
|
|
118
|
+
path: str,
|
|
119
|
+
timeout: float | None = None,
|
|
120
|
+
socket_options: Iterable[tuple[int, int, int | bytes]] | None = None,
|
|
121
|
+
) -> httpcore.AsyncNetworkStream:
|
|
122
|
+
raise ConnectorInvocationError("Connector Unix socket transport is not allowed.")
|
|
123
|
+
|
|
124
|
+
async def sleep(self, seconds: float) -> None:
|
|
125
|
+
await self._network_backend.sleep(seconds)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _PinnedPublicAsyncTransport(httpx.AsyncHTTPTransport):
|
|
129
|
+
"""HTTPX transport whose connection pool uses connect-time DNS pinning."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, resolver: Resolver) -> None:
|
|
132
|
+
super().__init__(retries=0)
|
|
133
|
+
self._pool = httpcore.AsyncConnectionPool(
|
|
134
|
+
retries=0,
|
|
135
|
+
network_backend=_PinnedPublicNetworkBackend(resolver),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_expiry(value: Any) -> datetime:
|
|
140
|
+
if not isinstance(value, str) or not value:
|
|
141
|
+
raise ConnectorInvocationError("Connector response expires_at is required.")
|
|
142
|
+
try:
|
|
143
|
+
expiry = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
raise ConnectorInvocationError("Connector response expires_at is invalid.") from exc
|
|
146
|
+
if expiry.tzinfo is None:
|
|
147
|
+
raise ConnectorInvocationError("Connector response expires_at must include a timezone.")
|
|
148
|
+
now = datetime.now(timezone.utc)
|
|
149
|
+
remaining = (expiry.astimezone(timezone.utc) - now).total_seconds()
|
|
150
|
+
if remaining <= 0:
|
|
151
|
+
raise ConnectorInvocationError("Connector response has expired.")
|
|
152
|
+
if remaining > MAX_RESPONSE_LIFETIME_SECONDS:
|
|
153
|
+
raise ConnectorInvocationError("Connector response expiry exceeds the allowed lifetime.")
|
|
154
|
+
return expiry
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_json(instance: Any, schema: dict[str, Any], *, label: str) -> None:
|
|
158
|
+
try:
|
|
159
|
+
Draft202012Validator.check_schema(schema)
|
|
160
|
+
Draft202012Validator(schema).validate(instance)
|
|
161
|
+
except SchemaError as exc:
|
|
162
|
+
raise ConnectorInvocationError(f"Connector {label} schema is invalid: {exc.message}") from exc
|
|
163
|
+
except ValidationError as exc:
|
|
164
|
+
raise ConnectorInvocationError(f"Connector {label} does not match its schema: {exc.message}") from exc
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _reject_embedded_write_payload(result: Any) -> None:
|
|
168
|
+
pending = [result]
|
|
169
|
+
while pending:
|
|
170
|
+
value = pending.pop()
|
|
171
|
+
if isinstance(value, dict):
|
|
172
|
+
for key, item in value.items():
|
|
173
|
+
if str(key).lower() in RESERVED_WRITE_RESULT_KEYS:
|
|
174
|
+
raise ConnectorInvocationError(
|
|
175
|
+
f"Read-only connector result contains reserved write field: {key}."
|
|
176
|
+
)
|
|
177
|
+
pending.append(item)
|
|
178
|
+
elif isinstance(value, list):
|
|
179
|
+
pending.extend(value)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ConnectorReadClient:
|
|
183
|
+
"""Invoke enabled read-only connector tools with strict identity binding."""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
registry: ConnectorRegistry | None = None,
|
|
188
|
+
*,
|
|
189
|
+
http_client: httpx.AsyncClient | None = None,
|
|
190
|
+
resolver: Resolver | None = None,
|
|
191
|
+
):
|
|
192
|
+
self.registry = registry or ConnectorRegistry()
|
|
193
|
+
self._http_client = http_client
|
|
194
|
+
self._resolver = resolver or _default_resolver
|
|
195
|
+
|
|
196
|
+
async def invoke(
|
|
197
|
+
self,
|
|
198
|
+
host_tool_name: str,
|
|
199
|
+
arguments: dict[str, Any],
|
|
200
|
+
*,
|
|
201
|
+
context: dict[str, Any] | None = None,
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
tool = resolve_connector_tool(host_tool_name, self.registry, include_write=False)
|
|
204
|
+
if tool["read_only"] is not True:
|
|
205
|
+
raise ConnectorInvocationError("ConnectorReadClient accepts only read-only tools.")
|
|
206
|
+
if not isinstance(arguments, dict):
|
|
207
|
+
raise ConnectorInvocationError("Connector tool arguments must be an object.")
|
|
208
|
+
_validate_json(arguments, tool["input_schema"], label="tool input")
|
|
209
|
+
|
|
210
|
+
connector_id = str(tool["connector_id"])
|
|
211
|
+
connector_version = str(tool["connector_version"])
|
|
212
|
+
manifest = self.registry.load_manifest(connector_id, connector_version)
|
|
213
|
+
transport = manifest["transport"]
|
|
214
|
+
base_url = str(transport["url"]).rstrip("/")
|
|
215
|
+
_validate_endpoint_url(base_url)
|
|
216
|
+
if self._http_client is not None:
|
|
217
|
+
_assert_public_endpoint(base_url, self._resolver)
|
|
218
|
+
request_id = str(uuid.uuid4())
|
|
219
|
+
|
|
220
|
+
safe_context: dict[str, Any] = {}
|
|
221
|
+
supplied_context = context if isinstance(context, dict) else {}
|
|
222
|
+
for field in ("chain", "network", "chain_id"):
|
|
223
|
+
value = supplied_context.get(field)
|
|
224
|
+
if value is not None:
|
|
225
|
+
safe_context[field] = value
|
|
226
|
+
if manifest["permissions"].get("wallet_address") is True:
|
|
227
|
+
wallet_address = supplied_context.get("wallet_address")
|
|
228
|
+
if isinstance(wallet_address, str) and wallet_address.strip():
|
|
229
|
+
safe_context["wallet_address"] = wallet_address.strip()
|
|
230
|
+
|
|
231
|
+
request_payload = {
|
|
232
|
+
"protocol_version": 1,
|
|
233
|
+
"request_id": request_id,
|
|
234
|
+
"connector": {
|
|
235
|
+
"id": connector_id,
|
|
236
|
+
"version": connector_version,
|
|
237
|
+
**(
|
|
238
|
+
{"artifact_digest": manifest["artifact_digest"]}
|
|
239
|
+
if manifest.get("artifact_digest")
|
|
240
|
+
else {}
|
|
241
|
+
),
|
|
242
|
+
},
|
|
243
|
+
"tool": str(tool["connector_tool"]),
|
|
244
|
+
"arguments": arguments,
|
|
245
|
+
"context": safe_context,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
owns_client = self._http_client is None
|
|
249
|
+
client = self._http_client or httpx.AsyncClient(
|
|
250
|
+
transport=_PinnedPublicAsyncTransport(self._resolver),
|
|
251
|
+
follow_redirects=False,
|
|
252
|
+
trust_env=False,
|
|
253
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
try:
|
|
257
|
+
response = await client.post(
|
|
258
|
+
f"{base_url}/invoke",
|
|
259
|
+
json=request_payload,
|
|
260
|
+
timeout=float(transport.get("timeout_ms", 10000)) / 1000.0,
|
|
261
|
+
)
|
|
262
|
+
except httpx.HTTPError as exc:
|
|
263
|
+
raise ConnectorInvocationError(f"Connector request failed: {exc}") from exc
|
|
264
|
+
finally:
|
|
265
|
+
if owns_client:
|
|
266
|
+
await client.aclose()
|
|
267
|
+
|
|
268
|
+
if 300 <= response.status_code < 400:
|
|
269
|
+
raise ConnectorInvocationError("Connector redirects are not allowed.")
|
|
270
|
+
if response.status_code != 200:
|
|
271
|
+
raise ConnectorInvocationError(f"Connector returned HTTP {response.status_code}.")
|
|
272
|
+
if len(response.content) > MAX_CONNECTOR_RESPONSE_BYTES:
|
|
273
|
+
raise ConnectorInvocationError("Connector response exceeds the size limit.")
|
|
274
|
+
try:
|
|
275
|
+
payload = response.json()
|
|
276
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
277
|
+
raise ConnectorInvocationError("Connector response is not valid JSON.") from exc
|
|
278
|
+
if not isinstance(payload, dict):
|
|
279
|
+
raise ConnectorInvocationError("Connector response root must be an object.")
|
|
280
|
+
if payload.get("protocol_version") != 1 or payload.get("request_id") != request_id:
|
|
281
|
+
raise ConnectorInvocationError("Connector response binding is invalid.")
|
|
282
|
+
response_connector = payload.get("connector")
|
|
283
|
+
if not isinstance(response_connector, dict):
|
|
284
|
+
raise ConnectorInvocationError("Connector response identity is missing.")
|
|
285
|
+
if (
|
|
286
|
+
response_connector.get("id") != connector_id
|
|
287
|
+
or response_connector.get("version") != connector_version
|
|
288
|
+
):
|
|
289
|
+
raise ConnectorInvocationError("Connector response identity does not match the request.")
|
|
290
|
+
expected_artifact = manifest.get("artifact_digest")
|
|
291
|
+
if expected_artifact and response_connector.get("artifact_digest") != expected_artifact:
|
|
292
|
+
raise ConnectorInvocationError("Connector response artifact digest does not match.")
|
|
293
|
+
if payload.get("tool") != tool["connector_tool"]:
|
|
294
|
+
raise ConnectorInvocationError("Connector response tool does not match the request.")
|
|
295
|
+
if payload.get("kind") != "read_result":
|
|
296
|
+
raise ConnectorInvocationError("Read-only connector returned a non-read result.")
|
|
297
|
+
_parse_expiry(payload.get("expires_at"))
|
|
298
|
+
result = payload.get("result")
|
|
299
|
+
_reject_embedded_write_payload(result)
|
|
300
|
+
_validate_json(result, tool["output_schema"], label="tool output")
|
|
301
|
+
return {
|
|
302
|
+
"connector_id": connector_id,
|
|
303
|
+
"connector_version": connector_version,
|
|
304
|
+
"tool": str(tool["connector_tool"]),
|
|
305
|
+
"untrusted_external_data": True,
|
|
306
|
+
"result": result,
|
|
307
|
+
"expires_at": payload["expires_at"],
|
|
308
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Fail-closed local policy for unsigned write-capable connector intents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agent_wallet.connectors.manifest import DIGEST_PATTERN, validate_connector_manifest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
EVM_ADDRESS_PATTERN = re.compile(r"^0x[0-9a-fA-F]{40}$")
|
|
13
|
+
HEX_DATA_PATTERN = re.compile(r"^0x(?:[0-9a-fA-F]{2})*$")
|
|
14
|
+
UINT_PATTERN = re.compile(r"^(0|[1-9][0-9]*)$")
|
|
15
|
+
ERC20_APPROVE_SELECTOR = "0x095ea7b3"
|
|
16
|
+
MAX_UINT256 = (1 << 256) - 1
|
|
17
|
+
MAX_INTENT_LIFETIME_SECONDS = 300
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectorIntentPolicyError(ValueError):
|
|
21
|
+
"""Raised when a connector write intent is unsafe or incorrectly bound."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _require_object(value: Any, field: str) -> dict[str, Any]:
|
|
25
|
+
if not isinstance(value, dict):
|
|
26
|
+
raise ConnectorIntentPolicyError(f"{field} must be an object.")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_uint(value: Any, field: str, *, positive: bool = False) -> int:
|
|
31
|
+
if not isinstance(value, str) or not UINT_PATTERN.fullmatch(value):
|
|
32
|
+
raise ConnectorIntentPolicyError(f"{field} must be an unsigned integer string.")
|
|
33
|
+
number = int(value)
|
|
34
|
+
if number >= 1 << 256:
|
|
35
|
+
raise ConnectorIntentPolicyError(f"{field} exceeds uint256.")
|
|
36
|
+
if positive and number <= 0:
|
|
37
|
+
raise ConnectorIntentPolicyError(f"{field} must be greater than zero.")
|
|
38
|
+
return number
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _require_address(value: Any, field: str) -> str:
|
|
42
|
+
if not isinstance(value, str) or not EVM_ADDRESS_PATTERN.fullmatch(value):
|
|
43
|
+
raise ConnectorIntentPolicyError(f"{field} must be an EVM address.")
|
|
44
|
+
return value.lower()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_expiry(value: Any) -> str:
|
|
48
|
+
if not isinstance(value, str) or not value:
|
|
49
|
+
raise ConnectorIntentPolicyError("expires_at is required.")
|
|
50
|
+
try:
|
|
51
|
+
expiry = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise ConnectorIntentPolicyError("expires_at is invalid.") from exc
|
|
54
|
+
if expiry.tzinfo is None:
|
|
55
|
+
raise ConnectorIntentPolicyError("expires_at must include a timezone.")
|
|
56
|
+
remaining = (expiry.astimezone(timezone.utc) - datetime.now(timezone.utc)).total_seconds()
|
|
57
|
+
if remaining <= 0:
|
|
58
|
+
raise ConnectorIntentPolicyError("Connector intent has expired.")
|
|
59
|
+
if remaining > MAX_INTENT_LIFETIME_SECONDS:
|
|
60
|
+
raise ConnectorIntentPolicyError("Connector intent expiry exceeds the allowed lifetime.")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _evm_contract_policy(manifest: dict[str, Any], chain_id: int) -> dict[str, set[str]]:
|
|
65
|
+
result: dict[str, set[str]] = {}
|
|
66
|
+
for raw_policy in manifest.get("chains") or []:
|
|
67
|
+
if not isinstance(raw_policy, dict) or raw_policy.get("chain") != "evm":
|
|
68
|
+
continue
|
|
69
|
+
chain_ids = raw_policy.get("chain_ids")
|
|
70
|
+
if not isinstance(chain_ids, list) or chain_id not in chain_ids:
|
|
71
|
+
continue
|
|
72
|
+
for raw_contract in raw_policy.get("contracts") or []:
|
|
73
|
+
if not isinstance(raw_contract, dict) or raw_contract.get("chain_id") != chain_id:
|
|
74
|
+
continue
|
|
75
|
+
address = _require_address(raw_contract.get("address"), "manifest contract address")
|
|
76
|
+
selectors = raw_contract.get("selectors")
|
|
77
|
+
if not isinstance(selectors, list) or not selectors:
|
|
78
|
+
raise ConnectorIntentPolicyError("Manifest contract selectors are missing.")
|
|
79
|
+
result[address] = {str(selector).lower() for selector in selectors}
|
|
80
|
+
if not result:
|
|
81
|
+
raise ConnectorIntentPolicyError(f"Connector is not allowed on EVM chain {chain_id}.")
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_tool(manifest: dict[str, Any], tool_name: Any) -> dict[str, Any]:
|
|
86
|
+
if not isinstance(tool_name, str):
|
|
87
|
+
raise ConnectorIntentPolicyError("tool is required.")
|
|
88
|
+
for tool in manifest["tools"]:
|
|
89
|
+
if isinstance(tool, dict) and tool.get("name") == tool_name:
|
|
90
|
+
if tool.get("read_only") is True:
|
|
91
|
+
raise ConnectorIntentPolicyError("A read-only connector tool cannot return an intent.")
|
|
92
|
+
return tool
|
|
93
|
+
raise ConnectorIntentPolicyError(f"Connector write tool is not declared: {tool_name}.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_evm_transaction_intent(
|
|
97
|
+
manifest_payload: dict[str, Any],
|
|
98
|
+
intent_payload: dict[str, Any],
|
|
99
|
+
*,
|
|
100
|
+
wallet_address: str,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
"""Validate an unsigned EVM intent and return an approval-safe summary.
|
|
103
|
+
|
|
104
|
+
This function does not simulate, sign, approve, or broadcast. Its output is
|
|
105
|
+
suitable as input to the later simulation and preview binding layers.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
manifest = validate_connector_manifest(manifest_payload)
|
|
109
|
+
intent = _require_object(intent_payload, "intent")
|
|
110
|
+
if manifest["trust"] != "verified_write":
|
|
111
|
+
raise ConnectorIntentPolicyError("Only verified_write connectors may return intents.")
|
|
112
|
+
if manifest["permissions"].get("transaction_intents") is not True:
|
|
113
|
+
raise ConnectorIntentPolicyError("Connector is not permitted to return transaction intents.")
|
|
114
|
+
if intent.get("protocol_version") != 1 or intent.get("kind") != "evm_transaction_intent":
|
|
115
|
+
raise ConnectorIntentPolicyError("Unsupported EVM connector intent version or kind.")
|
|
116
|
+
if intent.get("connector_id") != manifest["id"]:
|
|
117
|
+
raise ConnectorIntentPolicyError("Connector intent id does not match the manifest.")
|
|
118
|
+
if intent.get("connector_version") != manifest["version"]:
|
|
119
|
+
raise ConnectorIntentPolicyError("Connector intent version does not match the manifest.")
|
|
120
|
+
if intent.get("artifact_digest") != manifest.get("artifact_digest"):
|
|
121
|
+
raise ConnectorIntentPolicyError("Connector intent artifact digest does not match.")
|
|
122
|
+
quote_fingerprint = intent.get("quote_fingerprint")
|
|
123
|
+
if not isinstance(quote_fingerprint, str) or not DIGEST_PATTERN.fullmatch(quote_fingerprint):
|
|
124
|
+
raise ConnectorIntentPolicyError("quote_fingerprint must be a lowercase sha256 digest.")
|
|
125
|
+
tool = _write_tool(manifest, intent.get("tool"))
|
|
126
|
+
expires_at = _validate_expiry(intent.get("expires_at"))
|
|
127
|
+
|
|
128
|
+
chain_id = intent.get("chain_id")
|
|
129
|
+
if not isinstance(chain_id, int) or isinstance(chain_id, bool) or chain_id <= 0:
|
|
130
|
+
raise ConnectorIntentPolicyError("chain_id must be a positive integer.")
|
|
131
|
+
contract_policy = _evm_contract_policy(manifest, chain_id)
|
|
132
|
+
expected_wallet = _require_address(wallet_address, "wallet_address")
|
|
133
|
+
intent_from = _require_address(intent.get("from"), "from")
|
|
134
|
+
if intent_from != expected_wallet:
|
|
135
|
+
raise ConnectorIntentPolicyError("Connector intent from address does not match the wallet.")
|
|
136
|
+
|
|
137
|
+
calls = intent.get("calls")
|
|
138
|
+
if not isinstance(calls, list) or not 1 <= len(calls) <= 16:
|
|
139
|
+
raise ConnectorIntentPolicyError("calls must contain between 1 and 16 entries.")
|
|
140
|
+
normalized_calls: list[dict[str, Any]] = []
|
|
141
|
+
for index, raw_call in enumerate(calls):
|
|
142
|
+
call = _require_object(raw_call, f"calls[{index}]")
|
|
143
|
+
target = _require_address(call.get("to"), f"calls[{index}].to")
|
|
144
|
+
selectors = contract_policy.get(target)
|
|
145
|
+
if selectors is None:
|
|
146
|
+
raise ConnectorIntentPolicyError(f"calls[{index}] targets an unapproved contract.")
|
|
147
|
+
data = call.get("data")
|
|
148
|
+
if not isinstance(data, str) or not HEX_DATA_PATTERN.fullmatch(data) or len(data) < 10:
|
|
149
|
+
raise ConnectorIntentPolicyError(f"calls[{index}].data must contain EVM calldata.")
|
|
150
|
+
selector = data[:10].lower()
|
|
151
|
+
if selector == ERC20_APPROVE_SELECTOR:
|
|
152
|
+
raise ConnectorIntentPolicyError(
|
|
153
|
+
"Connector intents must declare approvals separately from protocol calls."
|
|
154
|
+
)
|
|
155
|
+
if selector not in selectors:
|
|
156
|
+
raise ConnectorIntentPolicyError(f"calls[{index}] uses an unapproved selector.")
|
|
157
|
+
value_wei = _require_uint(call.get("value_wei"), f"calls[{index}].value_wei")
|
|
158
|
+
normalized_calls.append(
|
|
159
|
+
{
|
|
160
|
+
"to": target,
|
|
161
|
+
"selector": selector,
|
|
162
|
+
"value_wei": str(value_wei),
|
|
163
|
+
"calldata_bytes": (len(data) - 2) // 2,
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
approvals = intent.get("approvals")
|
|
168
|
+
if not isinstance(approvals, list) or len(approvals) > 16:
|
|
169
|
+
raise ConnectorIntentPolicyError("approvals must be an array of at most 16 entries.")
|
|
170
|
+
normalized_approvals: list[dict[str, str]] = []
|
|
171
|
+
for index, raw_approval in enumerate(approvals):
|
|
172
|
+
approval = _require_object(raw_approval, f"approvals[{index}]")
|
|
173
|
+
token = _require_address(approval.get("token"), f"approvals[{index}].token")
|
|
174
|
+
spender = _require_address(approval.get("spender"), f"approvals[{index}].spender")
|
|
175
|
+
if spender not in contract_policy:
|
|
176
|
+
raise ConnectorIntentPolicyError(f"approvals[{index}] uses an unapproved spender.")
|
|
177
|
+
amount = _require_uint(
|
|
178
|
+
approval.get("amount_raw"), f"approvals[{index}].amount_raw", positive=True
|
|
179
|
+
)
|
|
180
|
+
if amount == MAX_UINT256:
|
|
181
|
+
raise ConnectorIntentPolicyError("Unlimited connector token approvals are prohibited.")
|
|
182
|
+
normalized_approvals.append(
|
|
183
|
+
{
|
|
184
|
+
"token": token,
|
|
185
|
+
"spender": spender,
|
|
186
|
+
"amount_raw": str(amount),
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
effects = intent.get("expected_effects")
|
|
191
|
+
if not isinstance(effects, list) or not 1 <= len(effects) <= 64:
|
|
192
|
+
raise ConnectorIntentPolicyError("expected_effects must contain between 1 and 64 entries.")
|
|
193
|
+
normalized_effects: list[dict[str, Any]] = []
|
|
194
|
+
for index, raw_effect in enumerate(effects):
|
|
195
|
+
effect = _require_object(raw_effect, f"expected_effects[{index}]")
|
|
196
|
+
effect_type = effect.get("type")
|
|
197
|
+
if effect_type not in {"asset", "debt", "position", "protocol_fee", "network_fee"}:
|
|
198
|
+
raise ConnectorIntentPolicyError(f"expected_effects[{index}].type is unsupported.")
|
|
199
|
+
direction = effect.get("direction")
|
|
200
|
+
if direction not in {"debit", "credit", "increase", "decrease"}:
|
|
201
|
+
raise ConnectorIntentPolicyError(f"expected_effects[{index}].direction is unsupported.")
|
|
202
|
+
asset = effect.get("asset")
|
|
203
|
+
if not isinstance(asset, str) or not asset.strip() or len(asset) > 128:
|
|
204
|
+
raise ConnectorIntentPolicyError(f"expected_effects[{index}].asset is invalid.")
|
|
205
|
+
amount = _require_uint(effect.get("amount"), f"expected_effects[{index}].amount")
|
|
206
|
+
normalized_effects.append(
|
|
207
|
+
{
|
|
208
|
+
"type": effect_type,
|
|
209
|
+
"asset": asset.strip(),
|
|
210
|
+
"direction": direction,
|
|
211
|
+
"amount": str(amount),
|
|
212
|
+
**(
|
|
213
|
+
{"recipient": str(effect["recipient"])}
|
|
214
|
+
if isinstance(effect.get("recipient"), str) and effect["recipient"]
|
|
215
|
+
else {}
|
|
216
|
+
),
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"operation": "AgentLayer connector EVM transaction",
|
|
222
|
+
"connector_id": str(manifest["id"]),
|
|
223
|
+
"connector_version": str(manifest["version"]),
|
|
224
|
+
"artifact_digest": str(manifest["artifact_digest"]),
|
|
225
|
+
"tool": str(tool["name"]),
|
|
226
|
+
"chain": "evm",
|
|
227
|
+
"chain_id": chain_id,
|
|
228
|
+
"from": expected_wallet,
|
|
229
|
+
"quote_fingerprint": quote_fingerprint,
|
|
230
|
+
"expires_at": expires_at,
|
|
231
|
+
"calls": normalized_calls,
|
|
232
|
+
"approvals": normalized_approvals,
|
|
233
|
+
"expected_effects": normalized_effects,
|
|
234
|
+
"validation": {
|
|
235
|
+
"verified_connector": True,
|
|
236
|
+
"wallet_bound": True,
|
|
237
|
+
"targets_allowlisted": True,
|
|
238
|
+
"selectors_allowlisted": True,
|
|
239
|
+
"approvals_bounded": True,
|
|
240
|
+
"simulated": False,
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
|