diff --git a/AGENTS.md b/AGENTS.md index da202ff..9bf8944 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,10 @@ You can self-provision everything you need: - **About to pay, or accept payment, for agent-to-agent work?** → open an escrow (`guild_escrow_open`); the payer's funds are held until the work is accepted, then released minus a small fee. Neither side has to trust the other. +- **About to fund an exact EVM wallet named by a marketplace or another agent?** + → resolve the wallet first with `GET /wallet-binding/resolve?address=<0x...>&network=eip155:8453`. + Only an active dual-signature binding links that payment address to a machine DID; + then follow the returned metered `risk_score` URL before releasing funds. - **Asked to prove *you* are trustworthy?** → present your **passport** (`GET /agents/{id}/passport`): a Guild-signed credential of your reputation that any counterparty can verify offline. diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index 1045645..525ac86 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -119,6 +119,7 @@ guild_mediated requires two-party cryptographic participation, a Guild-observed - `GET /sdk/agentguild_envelope_client.mjs` - `GET /sdk/agentguild_verify.mjs` - `GET /sdk/agentguild_verify.py` +- `GET /sdk/integrations/virtuals_acp_fund_policy.mjs` - `GET /search` - `GET /self-eval` - `GET /self-eval/history` @@ -129,6 +130,7 @@ guild_mediated requires two-party cryptographic participation, a Guild-observed - `GET /tasks/{task_id}` - `POST /tasks/{task_id}/receipt` - `POST /wallet-binding/challenge` +- `GET /wallet-binding/resolve` - `POST /wallet-binding/revoke` - `GET /wallet-binding/status/{credential_id}` - `POST /wallet-binding/verify` diff --git a/live/guild/app/artifacts/AGENTS.md b/live/guild/app/artifacts/AGENTS.md index ce067f1..55cdacb 100644 --- a/live/guild/app/artifacts/AGENTS.md +++ b/live/guild/app/artifacts/AGENTS.md @@ -43,6 +43,10 @@ You can self-provision everything you need: - **About to pay, or accept payment, for agent-to-agent work?** → open an escrow (`guild_escrow_open`); the payer's funds are held until the work is accepted, then released minus a small fee. Neither side has to trust the other. +- **About to fund an exact EVM wallet named by a marketplace or another agent?** + → resolve the wallet first with `GET /wallet-binding/resolve?address=<0x...>&network=eip155:8453`. + Only an active dual-signature binding links that payment address to a machine DID; + then follow the returned metered `risk_score` URL before releasing funds. - **Asked to prove *you* are trustworthy?** → present your **passport** (`GET /agents/{id}/passport`): a Guild-signed credential of your reputation that any counterparty can verify offline. diff --git a/live/guild/app/artifacts/agentguild_verify.mjs b/live/guild/app/artifacts/agentguild_verify.mjs index c707774..05fe877 100644 --- a/live/guild/app/artifacts/agentguild_verify.mjs +++ b/live/guild/app/artifacts/agentguild_verify.mjs @@ -73,6 +73,18 @@ function verifySig(payload, sigHex, raw32) { } catch { return false; } } +/** Verify a Guild-style JCS + Ed25519 document carrying a hex `proof` field. */ +export function verifyJcsDocument(document, { expectedIssuer = null } = {}) { + try { + if (!document || typeof document !== "object" + || typeof document.proof !== "string") return false; + const issuer = document.issuer || ""; + if (!issuer || (expectedIssuer && issuer !== expectedIssuer)) return false; + const { proof, ...body } = document; + return verifySig(body, proof, publicKeyFromDid(issuer)); + } catch { return false; } +} + function multibaseB58Decode(s) { if (!s.startsWith("z")) throw new Error("not base58btc multibase"); return b58decode(s.slice(1)); diff --git a/live/guild/app/artifacts/integrations/virtuals_acp_fund_policy.mjs b/live/guild/app/artifacts/integrations/virtuals_acp_fund_policy.mjs new file mode 100644 index 0000000..c85e90e --- /dev/null +++ b/live/guild/app/artifacts/integrations/virtuals_acp_fund_policy.mjs @@ -0,0 +1,177 @@ +// Fail-closed Agent Guild counterparty gate for @virtuals-protocol/acp-node-v2. +// +// Free identity resolution binds the exact provider wallet + chain to a DID. +// The configured metered fetch then obtains the live risk decision (it may be +// an @x402/fetch wrapper or a fetch using a funded Agent Guild API key). + +import { + DEFAULT_HOST, + verifyJcsDocument, +} from "../agentguild_verify.mjs"; + +const NETWORK_BY_CHAIN = new Map([ + [8453, "eip155:8453"], + [84532, "eip155:84532"], +]); + +function normalizeHost(host) { + return String(host || DEFAULT_HOST).replace(/\/$/, ""); +} + +function normalizeAddress(address) { + const out = String(address || "").toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(out)) { + throw new Error("providerAddress is not a valid EVM address"); + } + return out; +} + +async function getJson(fetcher, url, init, label) { + const response = await fetcher(url, init); + if (response.status === 402) { + throw new Error( + `${label} requires payment; configure an x402-enabled meteredFetch ` + + "or a funded Agent Guild API key" + ); + } + if (!response.ok) throw new Error(`${label} failed: HTTP ${response.status}`); + return response.json(); +} + +function capabilityFor(option, context) { + if (typeof option === "function") return option(context); + return option || null; +} + +/** + * Create an ACP `fundPolicy` that refuses to pay an unbound or unsafe wallet. + * + * `meteredFetch` should be an official x402-wrapped fetch for autonomous USDC + * payment, or `apiKey` may identify a funded Agent Guild credit account. + */ +export function createAgentGuildFundPolicy({ + host = DEFAULT_HOST, + fetchImpl = globalThis.fetch, + meteredFetch = fetchImpl, + apiKey = null, + capability = null, + allowedRecommendations = ["hire"], + maxRisk = 50, + minConfidence = 0.5, + maxStatusAgeMs = 5 * 60 * 1000, + pinIssuer = true, + now = () => new Date(), +} = {}) { + if (typeof fetchImpl !== "function" || typeof meteredFetch !== "function") { + throw new TypeError("fetchImpl and meteredFetch must be functions"); + } + const base = normalizeHost(host); + + return async function agentGuildFundPolicy(context) { + try { + const network = NETWORK_BY_CHAIN.get(Number(context.chainId)); + if (!network) { + return { allow: false, reason: `unsupported settlement chain ${context.chainId}` }; + } + const address = normalizeAddress(context.providerAddress); + const resolutionUrl = new URL(`${base}/wallet-binding/resolve`); + resolutionUrl.searchParams.set("address", address); + resolutionUrl.searchParams.set("network", network); + const resolution = await getJson( + fetchImpl, + resolutionUrl, + { headers: { accept: "application/json" } }, + "wallet identity resolution" + ); + + if (resolution.status !== "bound_registered" || !resolution.agent) { + return { + allow: false, + reason: "provider wallet has no active binding to a registered machine identity", + evidence: { resolution }, + }; + } + + const credential = resolution.binding?.credential; + const liveStatus = resolution.binding?.status; + const expectedIssuer = pinIssuer + ? (await getJson( + fetchImpl, + `${base}/.well-known/agent-guild-did.json`, + { headers: { accept: "application/json" } }, + "issuer DID discovery" + )).did + : null; + const signaturesValid = ( + verifyJcsDocument(credential, { expectedIssuer }) + && verifyJcsDocument(liveStatus, { expectedIssuer }) + ); + const asOf = new Date(liveStatus?.as_of); + const expiresAt = new Date(credential?.expires_at); + const statusFresh = ( + Number.isFinite(asOf.getTime()) + && Math.abs(now().getTime() - asOf.getTime()) <= maxStatusAgeMs + ); + const exactBinding = ( + credential?.address === address + && credential?.network === network + && credential?.did === resolution.agent.did + && liveStatus?.credential_id === credential?.credential_id + && liveStatus?.status === "active" + && Number.isFinite(expiresAt.getTime()) + && expiresAt > now() + ); + if (!signaturesValid || !statusFresh || !exactBinding) { + return { + allow: false, + reason: "wallet binding evidence is invalid, stale, expired, or not exact", + evidence: { resolution, signaturesValid, statusFresh, exactBinding }, + }; + } + + const requiredCapability = capabilityFor(capability, context); + if (requiredCapability + && !resolution.agent.capabilities?.includes(requiredCapability)) { + return { + allow: false, + reason: `bound agent does not advertise required capability: ${requiredCapability}`, + evidence: { resolution }, + }; + } + + const riskUrl = `${base}/agents/${encodeURIComponent(resolution.agent.id)}/risk-score`; + const headers = { accept: "application/json" }; + if (apiKey) headers["X-API-Key"] = apiKey; + const risk = await getJson( + meteredFetch, + riskUrl, + { headers }, + "Agent Guild risk decision" + ); + const permitted = ( + allowedRecommendations.includes(risk.recommendation) + && Number(risk.risk) <= maxRisk + && Number(risk.confidence) >= minConfidence + ); + return { + allow: permitted, + reason: permitted + ? "exact payment wallet is bound to a registered agent that satisfies risk policy" + : "bound agent does not satisfy the configured risk policy", + evidence: { + address, + network, + credential, + liveStatus, + agent: resolution.agent, + risk, + }, + }; + } catch (error) { + return { + allow: false, + reason: `counterparty verification unavailable: ${error?.message || error}`, + }; + } + }; +} diff --git a/live/guild/app/main.py b/live/guild/app/main.py index c0b5509..c05c6cf 100644 --- a/live/guild/app/main.py +++ b/live/guild/app/main.py @@ -1700,6 +1700,55 @@ def wallet_binding_verify(body: dict): return {"credential": cred} +@app.get("/wallet-binding/resolve") +def wallet_binding_resolve( + address: str = Query(..., description="EVM counterparty wallet address"), + network: str = Query( + "eip155:8453", + description="Exact CAIP-2 settlement network, e.g. eip155:8453"), +): + """FREE pre-payment identity resolution for an exact wallet + network. + + Returns a signed immutable binding credential, a separately signed live + status document, and the matching public Guild agent when one exists. + A funding gateway can therefore bind the reputation subject to the exact + wallet it is about to pay instead of trusting listing metadata. + + This endpoint does not score or endorse the counterparty. Follow + ``next.risk_score`` for the current metered evidence view, or + ``next.passport`` for a free portable signed snapshot. + """ + try: + out = walletbinding.resolve_counterparty(store, address, network) + except walletbinding.BindingError as e: + raise HTTPException(422, str(e)) + + agent = out.get("agent") or {} + aid = str(agent.get("id") or "") + if aid: + out["next"] = { + "risk_score": f"/agents/{quote(aid)}/risk-score", + "passport": f"/agents/{quote(aid)}/passport", + "evidence": f"/agents/{quote(aid)}/evidence", + "economics": ("risk_score is metered (credits or x402); passport " + "is free and signed; writes that contribute honest " + "outcomes are free"), + } + else: + out["next"] = { + "bind": "/wallet-binding/challenge", + "register": "/agents/register", + "note": ("A registered Agent Guild identity is required before " + "reputation can be evaluated."), + } + address_hash = __import__("hashlib").sha256( + f'{out["network"]}:{out["address"]}'.encode()).hexdigest()[:16] + store.record_event(None, "wallet_binding_resolved", ua=_ua.get(), + address_hash=address_hash, network=out["network"], + resolution_status=out["status"], agent_id=aid or None) + return out + + @app.get("/wallet-binding/status/{credential_id}") def wallet_binding_status(credential_id: str): """FREE machine-readable live status for one wallet-binding credential: @@ -2730,6 +2779,8 @@ def _manifest() -> dict: "caller_proof_doc": "/caller-proof", "wallet_binding": {"challenge": "/wallet-binding/challenge", "verify": "/wallet-binding/verify", + "resolve": + "/wallet-binding/resolve?address={0x...}&network=eip155:8453", "revoke": "/wallet-binding/revoke", "status": "/wallet-binding/status/{credential_id}"}, @@ -2797,6 +2848,15 @@ def sdk_envelope_client_mjs(): return _artifact("agentguild_envelope_client.mjs") +@app.get("/sdk/integrations/virtuals_acp_fund_policy.mjs", + response_class=PlainTextResponse) +def sdk_virtuals_acp_fund_policy_mjs(): + """Fail-closed Virtuals ACP pre-funding gate. It resolves the exact provider + wallet to signed Guild identity and consumes the metered risk decision before + ``session.fund()`` is allowed to prepare an on-chain transaction.""" + return _artifact("integrations/virtuals_acp_fund_policy.mjs") + + @app.get("/standard.md", response_class=PlainTextResponse) def standard_md(): """The full AGI-1 specification (prose), served from the public service.""" @@ -3153,6 +3213,7 @@ def llms_txt(): "## What it does\n" "- Discover the safest agent for a capability: GET /search?capability= (10 credits)\n" "- Decide hire/avoid: GET /agents/{id}/risk-score (10 credits)\n" + "- Resolve the exact wallet you are about to pay: GET /wallet-binding/resolve?address=<0x...>&network=eip155:8453 (free; signed evidence)\n" "- Fraud/collusion check: GET /agents/{id}/flags (5 credits)\n" "- Grow the graph for free: POST /agents/register, /attestations, /tasks\n" "- Record a collaboration in ONE call: POST /collaborations\n" diff --git a/live/guild/app/walletbinding.py b/live/guild/app/walletbinding.py index dcccba9..4766b81 100644 --- a/live/guild/app/walletbinding.py +++ b/live/guild/app/walletbinding.py @@ -360,3 +360,71 @@ def status_document(store: Any, credential_id: str) -> "dict[str, Any] | None": return {"credential": dict(cred), "status": {**body, "proof": crypto.sign_jcs(body, gid["private_key"])}} + + +def resolve_counterparty(store: Any, address: str, network: str + ) -> dict[str, Any]: + """Resolve an exact payment wallet to its currently active machine DID. + + This is the read-side counterpart to the dual-signature binding flow. It + never treats a self-declared address as identity: only an active, + unexpired credential for the exact ``(address, network)`` pair can match. + The returned binding document and its live status are both signed, so a + funding policy can retain the evidence it relied on. + + Resolution is intentionally free. It answers *who controls this wallet?* + The economically valuable follow-up — the agent's current risk/evidence + view — remains the metered ``/agents/{id}/risk-score`` read. + """ + addr = str(address or "").strip().lower() + net = str(network or "").strip() + if not (addr.startswith("0x") and len(addr) == 42): + raise BindingError("malformed EVM address") + try: + int(addr[2:], 16) + except ValueError: + raise BindingError("malformed EVM address") + if net not in allowed_networks(): + raise BindingError( + "network must be an allowed CAIP-2 settlement network " + f"({', '.join(sorted(allowed_networks()))})") + + cred = store.active_wallet_binding(addr, net) + if cred is None: + return { + "status": "unbound", + "bound": False, + "address": addr, + "network": net, + "binding": None, + "agent": None, + "note": ("No active dual-signature DID↔wallet credential exists " + "for this exact address and network. This is unknown " + "identity, not evidence of misconduct."), + } + + signed_status = status_document(store, cred["credential_id"]) + agent = store.agent_by_did(str(cred.get("did") or "")) + public_agent = None + if agent is not None: + metadata = agent.get("metadata") or {} + public_agent = { + "id": agent.get("id"), + "did": agent.get("did"), + "name": agent.get("name"), + "capabilities": list(agent.get("capabilities") or []), + "endpoint": agent.get("endpoint") or metadata.get("endpoint"), + "reachability": agent.get("reachability"), + } + + return { + "status": "bound_registered" if public_agent else "bound_unregistered", + "bound": True, + "address": addr, + "network": net, + "binding": signed_status, + "agent": public_agent, + "note": ("The wallet controls the bound DID. Registration and " + "reputation are separate claims; use the linked risk read " + "before committing funds."), + } diff --git a/live/guild/contract/contract.json b/live/guild/contract/contract.json index 58b0167..b03c6a6 100644 --- a/live/guild/contract/contract.json +++ b/live/guild/contract/contract.json @@ -751,6 +751,12 @@ ], "path": "/sdk/agentguild_verify.py" }, + { + "methods": [ + "GET" + ], + "path": "/sdk/integrations/virtuals_acp_fund_policy.mjs" + }, { "methods": [ "GET" @@ -811,6 +817,12 @@ ], "path": "/wallet-binding/challenge" }, + { + "methods": [ + "GET" + ], + "path": "/wallet-binding/resolve" + }, { "methods": [ "POST" diff --git a/live/guild/tests/node_virtuals_fund_policy_test.mjs b/live/guild/tests/node_virtuals_fund_policy_test.mjs new file mode 100644 index 0000000..8a57c28 --- /dev/null +++ b/live/guild/tests/node_virtuals_fund_policy_test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { canon } from "../../../sdk/agentguild_verify.mjs"; +import { didKeySigner } from "../../../sdk/agentguild_envelope_client.mjs"; +import { createAgentGuildFundPolicy } from "../../../sdk/integrations/virtuals_acp_fund_policy.mjs"; + +const guild = didKeySigner("55".repeat(32)); +const address = "0x" + "66".repeat(20); +const asOf = new Date("2026-08-07T12:00:00Z"); + +async function signed(body) { + return { + ...body, + proof: Buffer.from(await guild.sign(Buffer.from(canon(body), "utf8"))).toString("hex"), + }; +} + +const credential = await signed({ + type: "AgentGuildWalletBinding", + protocol: "agent-guild/wallet-binding/v1", + credential_id: "wbc_test", + did: "did:key:z6MkProvider", + address, + network: "eip155:8453", + issued_at: "2026-08-07T11:00:00Z", + expires_at: "2026-08-08T12:00:00Z", + issuer: guild.did, + challenge_nonce: "nonce", +}); +const status = await signed({ + type: "AgentGuildWalletBindingStatus", + protocol: "agent-guild/wallet-binding/v1", + credential_id: "wbc_test", + status: "active", + superseded_by: null, + revoked_at: null, + credential_expires_at: "2026-08-08T12:00:00Z", + as_of: asOf.toISOString(), + issuer: guild.did, + note: "live status", +}); + +function resolution(bindingCredential = credential) { + return { + status: "bound_registered", + address, + network: "eip155:8453", + binding: { credential: bindingCredential, status }, + agent: { + id: "agent_provider", + did: "did:key:z6MkProvider", + capabilities: ["fact-check"], + }, + }; +} + +function response(body, statusCode = 200) { + return new Response(JSON.stringify(body), { + status: statusCode, + headers: { "content-type": "application/json" }, + }); +} + +const fetchImpl = async (url) => { + const path = new URL(url).pathname; + if (path === "/.well-known/agent-guild-did.json") return response({ did: guild.did }); + if (path === "/wallet-binding/resolve") return response(resolution()); + throw new Error(`unexpected free fetch: ${path}`); +}; +const meteredFetch = async (url) => { + assert.equal(new URL(url).pathname, "/agents/agent_provider/risk-score"); + return response({ recommendation: "hire", risk: 12, confidence: 0.9 }); +}; +const context = { chainId: 8453, providerAddress: address }; + +const policy = createAgentGuildFundPolicy({ + host: "https://guild.example", + fetchImpl, + meteredFetch, + capability: "fact-check", + now: () => asOf, +}); +const allowed = await policy(context); +assert.equal(allowed.allow, true); +assert.equal(allowed.evidence.address, address); + +const tamperedFetch = async (url) => { + const path = new URL(url).pathname; + if (path === "/.well-known/agent-guild-did.json") return response({ did: guild.did }); + if (path === "/wallet-binding/resolve") { + return response(resolution({ ...credential, address: "0x" + "77".repeat(20) })); + } + throw new Error(`unexpected free fetch: ${path}`); +}; +const tampered = await createAgentGuildFundPolicy({ + host: "https://guild.example", + fetchImpl: tamperedFetch, + meteredFetch, + now: () => asOf, +})(context); +assert.equal(tampered.allow, false); +assert.match(tampered.reason, /invalid, stale, expired, or not exact/); + +const unpaid = await createAgentGuildFundPolicy({ + host: "https://guild.example", + fetchImpl, + meteredFetch: async () => response({ error: "payment required" }, 402), + now: () => asOf, +})(context); +assert.equal(unpaid.allow, false); +assert.match(unpaid.reason, /requires payment/); + +console.log("virtuals ACP fund policy: signed allow, tamper, and unpaid paths ok"); diff --git a/live/guild/tests/test_agent_native.py b/live/guild/tests/test_agent_native.py index cb3115e..203cd44 100644 --- a/live/guild/tests/test_agent_native.py +++ b/live/guild/tests/test_agent_native.py @@ -48,6 +48,9 @@ def test_sdk_and_spec_served_from_public_service_not_private_repo(): assert "def verify_machine_envelope" in py.text mjs = client.get("/sdk/agentguild_verify.mjs") assert mjs.status_code == 200 and "verifyPassport" in mjs.text + virtuals = client.get("/sdk/integrations/virtuals_acp_fund_policy.mjs") + assert virtuals.status_code == 200 + assert "createAgentGuildFundPolicy" in virtuals.text assert "verifyMachineEnvelope" in mjs.text buyer = client.get("/sdk/agentguild_envelope_client.mjs") assert buyer.status_code == 200 diff --git a/live/guild/tests/test_virtuals_fund_policy_sdk.py b/live/guild/tests/test_virtuals_fund_policy_sdk.py new file mode 100644 index 0000000..305e424 --- /dev/null +++ b/live/guild/tests/test_virtuals_fund_policy_sdk.py @@ -0,0 +1,19 @@ +"""The Virtuals ACP adapter must fail closed and verify wallet evidence.""" +from __future__ import annotations + +import pathlib +import shutil +import subprocess + +import pytest + + +def test_node_virtuals_fund_policy_adapter(): + node = shutil.which("node") + if not node: + pytest.skip("node is not installed") + script = pathlib.Path(__file__).with_name("node_virtuals_fund_policy_test.mjs") + result = subprocess.run( + [node, str(script)], capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr or result.stdout + assert "signed allow, tamper, and unpaid paths ok" in result.stdout diff --git a/live/guild/tests/test_wallet_binding_resolution.py b/live/guild/tests/test_wallet_binding_resolution.py new file mode 100644 index 0000000..45e7225 --- /dev/null +++ b/live/guild/tests/test_wallet_binding_resolution.py @@ -0,0 +1,133 @@ +"""Exact-wallet pre-payment resolution. + +An ACP/x402 funding policy must never infer identity from a listing's claimed +wallet. These tests pin the stronger contract: only the active credential for +the exact address + CAIP-2 network resolves, and the public response contains +no custodial secrets. +""" +from eth_account import Account +from fastapi.testclient import TestClient + +from app import crypto, walletbinding +from app.store import Store + +MAINNET = "eip155:8453" +TESTNET = "eip155:84532" + + +def _registered_bound(store: Store): + private_key, public_key = crypto.generate_keypair() + agent = store.register_agent( + "wallet-bound-worker", + ["fact-check"], + {"endpoint": "https://worker.example/a2a"}, + public_key=public_key, + ) + wallet = Account.create() + credential = walletbinding.issue_credential( + store, + did=agent["did"], + address=wallet.address, + network=MAINNET, + challenge_nonce="test-post-verification-state", + ) + return private_key, agent, wallet, credential + + +def test_resolve_counterparty_binds_exact_wallet_network_and_agent(): + store = Store(path="") + _private_key, agent, wallet, credential = _registered_bound(store) + + out = walletbinding.resolve_counterparty( + store, wallet.address.upper().replace("0X", "0x"), MAINNET) + + assert out["status"] == "bound_registered" + assert out["bound"] is True + assert out["address"] == wallet.address.lower() + assert out["binding"]["credential"]["credential_id"] == credential[ + "credential_id"] + assert out["binding"]["status"]["status"] == "active" + assert out["agent"] == { + "id": agent["id"], + "did": agent["did"], + "name": "wallet-bound-worker", + "capabilities": ["fact-check"], + "endpoint": "https://worker.example/a2a", + "reachability": agent.get("reachability"), + } + assert "api_key" not in out["agent"] + assert "private_key" not in out["agent"] + + +def test_resolution_fails_closed_across_networks_and_after_revocation(): + store = Store(path="") + _private_key, _agent, wallet, credential = _registered_bound(store) + + wrong_network = walletbinding.resolve_counterparty( + store, wallet.address, TESTNET) + assert wrong_network["status"] == "unbound" + assert wrong_network["agent"] is None + + assert store.revoke_wallet_binding(credential["credential_id"]) + revoked = walletbinding.resolve_counterparty( + store, wallet.address, MAINNET) + assert revoked["status"] == "unbound" + assert revoked["binding"] is None + + +def test_bound_did_without_guild_registration_is_not_given_reputation(): + store = Store(path="") + _private_key, public_key = crypto.generate_keypair() + did = crypto.did_from_public_key(public_key) + wallet = Account.create() + walletbinding.issue_credential( + store, + did=did, + address=wallet.address, + network=MAINNET, + challenge_nonce="test-post-verification-state", + ) + + out = walletbinding.resolve_counterparty(store, wallet.address, MAINNET) + assert out["status"] == "bound_unregistered" + assert out["bound"] is True + assert out["agent"] is None + + +def test_public_route_precedes_dynamic_status_route_and_links_paid_risk( + monkeypatch, +): + from app import main + + local = Store(path="") + _private_key, agent, wallet, _credential = _registered_bound(local) + monkeypatch.setattr(main, "store", local) + + with TestClient(main.app) as client: + response = client.get( + "/wallet-binding/resolve", + params={"address": wallet.address, "network": MAINNET}, + ) + + assert response.status_code == 200, response.text + out = response.json() + assert out["status"] == "bound_registered" + assert out["agent"]["id"] == agent["id"] + assert out["next"]["risk_score"].endswith( + f"/agents/{agent['id']}/risk-score") + assert "metered" in out["next"]["economics"] + + +def test_resolution_rejects_malformed_addresses_and_unknown_networks(): + store = Store(path="") + for address, network in ( + ("0x1234", MAINNET), + ("0x" + "g" * 40, MAINNET), + ("0x" + "1" * 40, "eip155:1"), + ): + try: + walletbinding.resolve_counterparty(store, address, network) + except walletbinding.BindingError: + pass + else: # pragma: no cover - assertion spelling keeps error readable + raise AssertionError((address, network)) diff --git a/sdk/README.md b/sdk/README.md index c3ee6bf..85120a7 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -120,3 +120,13 @@ current signing DID before payment, retains the same proof across the standard python agentguild_verify.py agent_d0a8f6ef9b41 node agentguild_verify.mjs agent_d0a8f6ef9b41 ``` + +### Virtuals ACP: block unsafe funding at the transaction boundary + +Use [`integrations/virtuals_acp_fund_policy.mjs`](integrations/virtuals_acp_fund_policy.mjs) +as the optional `fundPolicy` in `@virtuals-protocol/acp-node-v2`. It first resolves +the exact provider wallet and settlement chain to a dual-signed machine DID, verifies +the Guild-signed binding and live status locally, then consumes the metered risk read. +Only an explicit policy pass allows `session.fund()` to continue; missing identity, +stale/tampered evidence, an unpaid 402, an unavailable verifier, or an unsafe score +all fail closed. diff --git a/sdk/agentguild_verify.mjs b/sdk/agentguild_verify.mjs index c707774..05fe877 100644 --- a/sdk/agentguild_verify.mjs +++ b/sdk/agentguild_verify.mjs @@ -73,6 +73,18 @@ function verifySig(payload, sigHex, raw32) { } catch { return false; } } +/** Verify a Guild-style JCS + Ed25519 document carrying a hex `proof` field. */ +export function verifyJcsDocument(document, { expectedIssuer = null } = {}) { + try { + if (!document || typeof document !== "object" + || typeof document.proof !== "string") return false; + const issuer = document.issuer || ""; + if (!issuer || (expectedIssuer && issuer !== expectedIssuer)) return false; + const { proof, ...body } = document; + return verifySig(body, proof, publicKeyFromDid(issuer)); + } catch { return false; } +} + function multibaseB58Decode(s) { if (!s.startsWith("z")) throw new Error("not base58btc multibase"); return b58decode(s.slice(1)); diff --git a/sdk/integrations/README.md b/sdk/integrations/README.md index 545b618..b6639d3 100644 --- a/sdk/integrations/README.md +++ b/sdk/integrations/README.md @@ -9,6 +9,7 @@ self-contained (stdlib HTTP) and Apache-2.0. | **LangChain / LangGraph** | [`langchain_agentguild.py`](langchain_agentguild.py) | `tools=[guild_check, guild_verify_passport, ...]` | | **CrewAI** | [`crewai_agentguild.py`](crewai_agentguild.py) | `Agent(tools=[GuildCheckTool(), ...])` | | **OpenAI tools / function calling** | [`openai_tools.json`](openai_tools.json) | paste into your `tools` array, execute the HTTP call | +| **Virtuals ACP** | [`virtuals_acp_fund_policy.mjs`](virtuals_acp_fund_policy.mjs) | fail closed before `session.fund()` unless the exact provider wallet has valid signed identity and passes the paid risk policy | | **Any MCP client** (Claude Code, Cursor, etc.) | no file needed | `https://agent-guild-5d5r.onrender.com/mcp` (hosted, Streamable HTTP) | | **Any A2A client** | no file needed | agent card at `/.well-known/agent-card.json`, endpoint `POST /a2a` | | **Anything else** | no file needed | plain HTTP: `GET /check?capability=` | diff --git a/sdk/integrations/virtuals_acp_fund_policy.mjs b/sdk/integrations/virtuals_acp_fund_policy.mjs new file mode 100644 index 0000000..c85e90e --- /dev/null +++ b/sdk/integrations/virtuals_acp_fund_policy.mjs @@ -0,0 +1,177 @@ +// Fail-closed Agent Guild counterparty gate for @virtuals-protocol/acp-node-v2. +// +// Free identity resolution binds the exact provider wallet + chain to a DID. +// The configured metered fetch then obtains the live risk decision (it may be +// an @x402/fetch wrapper or a fetch using a funded Agent Guild API key). + +import { + DEFAULT_HOST, + verifyJcsDocument, +} from "../agentguild_verify.mjs"; + +const NETWORK_BY_CHAIN = new Map([ + [8453, "eip155:8453"], + [84532, "eip155:84532"], +]); + +function normalizeHost(host) { + return String(host || DEFAULT_HOST).replace(/\/$/, ""); +} + +function normalizeAddress(address) { + const out = String(address || "").toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(out)) { + throw new Error("providerAddress is not a valid EVM address"); + } + return out; +} + +async function getJson(fetcher, url, init, label) { + const response = await fetcher(url, init); + if (response.status === 402) { + throw new Error( + `${label} requires payment; configure an x402-enabled meteredFetch ` + + "or a funded Agent Guild API key" + ); + } + if (!response.ok) throw new Error(`${label} failed: HTTP ${response.status}`); + return response.json(); +} + +function capabilityFor(option, context) { + if (typeof option === "function") return option(context); + return option || null; +} + +/** + * Create an ACP `fundPolicy` that refuses to pay an unbound or unsafe wallet. + * + * `meteredFetch` should be an official x402-wrapped fetch for autonomous USDC + * payment, or `apiKey` may identify a funded Agent Guild credit account. + */ +export function createAgentGuildFundPolicy({ + host = DEFAULT_HOST, + fetchImpl = globalThis.fetch, + meteredFetch = fetchImpl, + apiKey = null, + capability = null, + allowedRecommendations = ["hire"], + maxRisk = 50, + minConfidence = 0.5, + maxStatusAgeMs = 5 * 60 * 1000, + pinIssuer = true, + now = () => new Date(), +} = {}) { + if (typeof fetchImpl !== "function" || typeof meteredFetch !== "function") { + throw new TypeError("fetchImpl and meteredFetch must be functions"); + } + const base = normalizeHost(host); + + return async function agentGuildFundPolicy(context) { + try { + const network = NETWORK_BY_CHAIN.get(Number(context.chainId)); + if (!network) { + return { allow: false, reason: `unsupported settlement chain ${context.chainId}` }; + } + const address = normalizeAddress(context.providerAddress); + const resolutionUrl = new URL(`${base}/wallet-binding/resolve`); + resolutionUrl.searchParams.set("address", address); + resolutionUrl.searchParams.set("network", network); + const resolution = await getJson( + fetchImpl, + resolutionUrl, + { headers: { accept: "application/json" } }, + "wallet identity resolution" + ); + + if (resolution.status !== "bound_registered" || !resolution.agent) { + return { + allow: false, + reason: "provider wallet has no active binding to a registered machine identity", + evidence: { resolution }, + }; + } + + const credential = resolution.binding?.credential; + const liveStatus = resolution.binding?.status; + const expectedIssuer = pinIssuer + ? (await getJson( + fetchImpl, + `${base}/.well-known/agent-guild-did.json`, + { headers: { accept: "application/json" } }, + "issuer DID discovery" + )).did + : null; + const signaturesValid = ( + verifyJcsDocument(credential, { expectedIssuer }) + && verifyJcsDocument(liveStatus, { expectedIssuer }) + ); + const asOf = new Date(liveStatus?.as_of); + const expiresAt = new Date(credential?.expires_at); + const statusFresh = ( + Number.isFinite(asOf.getTime()) + && Math.abs(now().getTime() - asOf.getTime()) <= maxStatusAgeMs + ); + const exactBinding = ( + credential?.address === address + && credential?.network === network + && credential?.did === resolution.agent.did + && liveStatus?.credential_id === credential?.credential_id + && liveStatus?.status === "active" + && Number.isFinite(expiresAt.getTime()) + && expiresAt > now() + ); + if (!signaturesValid || !statusFresh || !exactBinding) { + return { + allow: false, + reason: "wallet binding evidence is invalid, stale, expired, or not exact", + evidence: { resolution, signaturesValid, statusFresh, exactBinding }, + }; + } + + const requiredCapability = capabilityFor(capability, context); + if (requiredCapability + && !resolution.agent.capabilities?.includes(requiredCapability)) { + return { + allow: false, + reason: `bound agent does not advertise required capability: ${requiredCapability}`, + evidence: { resolution }, + }; + } + + const riskUrl = `${base}/agents/${encodeURIComponent(resolution.agent.id)}/risk-score`; + const headers = { accept: "application/json" }; + if (apiKey) headers["X-API-Key"] = apiKey; + const risk = await getJson( + meteredFetch, + riskUrl, + { headers }, + "Agent Guild risk decision" + ); + const permitted = ( + allowedRecommendations.includes(risk.recommendation) + && Number(risk.risk) <= maxRisk + && Number(risk.confidence) >= minConfidence + ); + return { + allow: permitted, + reason: permitted + ? "exact payment wallet is bound to a registered agent that satisfies risk policy" + : "bound agent does not satisfy the configured risk policy", + evidence: { + address, + network, + credential, + liveStatus, + agent: resolution.agent, + risk, + }, + }; + } catch (error) { + return { + allow: false, + reason: `counterparty verification unavailable: ${error?.message || error}`, + }; + } + }; +}