proof-aware-crypto-tooling-.../src/pacta/walletmcp.py
mrwulf 65772b3d2e warden hardening round: policy engine, ledger rotation, MCP UX, treasury LIVE, ops docs
Tier 2:
- request_signature decomposed into named gates (latch, freshness,
  intent, policy, signer, firewall)
- ledger: O(1) tail-read appends under a dedicated lock file (survives
  rotation rename); hash-chained segment rotation at policy
  ledger.rotate_at; verify-ledger walks all segments to genesis;
  archive tampering detected (tested)
- docs/threat-model.md (attacker matrix 1-9, proven-vs-trusted, design
  invariants) + docs/runbook-latch.md (diagnose-first recovery)
- lecture 10: executable corrupt-a-member exercise (capsule pin catches
  one appended byte), honest note on what the pin does NOT stop

Lightweight policy engine (POLICY_DENIED wired):
- policy.json: per-request/per-day amount ceilings, counterparty
  allow/deny lists, per-identity overrides; rules make their intent
  fields mandatory; daily sums from the ledger
-

Agent UX:
- signed refusal receipts travel inside MCP errors (receipt +
  receipt_path in structuredContent)
- airgap over MCP: request_signature signer=airgap + request_id,
  new airgap_pending tool; park -> list -> device answers -> complete
  (tested end-to-end)
- all 8 tools carry readOnly/destructive annotations
- sliding-window rate limiter per tool class (custody/verify/liveness);
  RATE_LIMITED refusal code; surface control, not ledgered

warden-treasury LIVE:
- treasury.py: stdlib base58, compact-u16, legacy+v0 wire parsing;
  every required signature quorum-verified over exact message bytes;
  completeness gap named in every verdict; RPC fetch uses response as
  bytes only
-
- live-quorum test: synthetic Solana tx signed with wallet key ->
  authentic via 4 proven forks; flipped byte -> not authentic

100 tests green (was 85).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:38:42 +02:00

379 lines
17 KiB
Python

"""warden's Model Context Protocol surface - the agent-native front door.
A dependency-free MCP server over stdio JSON-RPC 2.0. It follows the AX
canon distilled in docs/agent-native.md: outcome-first tools, strict
input schemas, and results that carry their own evidence so a calling
agent never has to trust an adjective. Errors are structured objects
(``code`` / ``missing`` / ``remediation``), not prose - a refused agent
gets a machine-actionable receipt it can hand its principal.
This is intentionally tiny and stdlib-only: an agent should be able to
read the whole trust surface in one sitting. It speaks enough of MCP
(``initialize``, ``tools/list``, ``tools/call``) to be driven by any MCP
client, and degrades to a plain JSON-RPC endpoint for scripts.
"""
from __future__ import annotations
import base64
import json
import sys
from pathlib import Path
from typing import Any, Callable
from .custodycard import build_custody_card, posture_challenge
from .wallet import Refusal, Wallet
PROTOCOL_VERSION = "2025-06-18"
SERVER_INFO = {"name": "warden", "version": "1.0.0"}
def _b64_to_bytes(field: str, value: Any) -> bytes:
if not isinstance(value, str):
raise _ToolError("MALFORMED_INTENT", f"{field} must be base64 string", [field], "send base64")
try:
return base64.b64decode(value, validate=True)
except Exception as exc: # noqa: BLE001
raise _ToolError("MALFORMED_INTENT", f"{field} is not valid base64: {exc}", [field], "re-encode as base64")
class _ToolError(Exception):
def __init__(
self,
code: str,
reason: str,
missing: list[str],
remediation: str,
receipt: dict[str, Any] | None = None,
receipt_path: str | None = None,
) -> None:
super().__init__(reason)
self.payload = {"code": code, "reason": reason, "missing": missing, "remediation": remediation}
if receipt is not None:
# The signed refusal receipt travels WITH the error: the refused
# agent can hand its principal a provable, stamped "the wallet
# said no, and this is why" instead of an unsigned anecdote.
self.payload["receipt"] = receipt
self.payload["receipt_path"] = receipt_path
def _refusal_error(refusal: Any) -> _ToolError:
return _ToolError(
refusal.code,
refusal.reason,
refusal.missing,
refusal.remediation,
receipt=refusal.receipt,
receipt_path=str(refusal.receipt_path) if refusal.receipt_path else None,
)
class _RateLimiter:
"""Sliding-window throttle per tool class. Liveness reads are cheap and
generous; custody mutations are scarce on purpose. Limits exist so a
hostile counterparty cannot grind the ledger or the signer; they are a
surface control, not a custody event - refusals here are NOT ledgered."""
WINDOW = 60.0
LIMITS = {"custody": 30, "verify": 120, "liveness": 240}
def __init__(self) -> None:
self._hits: dict[str, list[float]] = {}
def check(self, category: str) -> None:
import time
now = time.monotonic()
hits = self._hits.setdefault(category, [])
cutoff = now - self.WINDOW
while hits and hits[0] < cutoff:
hits.pop(0)
if len(hits) >= self.LIMITS[category]:
raise _ToolError(
"RATE_LIMITED",
f"{category} budget exhausted ({self.LIMITS[category]} calls / {int(self.WINDOW)}s)",
[],
"back off and retry after the window rolls; liveness reads have a higher budget than custody calls",
)
hits.append(now)
_TOOL_CATEGORY = {
"request_signature": "custody",
"verify_inbound": "verify",
"posture_challenge": "custody", # it produces a firewalled signature
"wallet_status": "liveness",
"custody_card": "liveness",
"list_incidents": "liveness",
"explain_refusal": "liveness",
"airgap_pending": "liveness",
}
TOOLS: list[dict[str, Any]] = [
{
"name": "wallet_status",
"description": "Custody posture: quorum members and tiers, latch state, ledger head and chain integrity, incident and refusal counts. Read this first.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
"annotations": {"readOnlyHint": True},
},
{
"name": "verify_inbound",
"description": "Run the quorum on an inbound (payload, signature, public_key). Acceptance requires unanimity of the proven verifiers; divergence is classified and, if unexplained, latches custody. All inputs base64.",
"inputSchema": {
"type": "object",
"properties": {
"payload_b64": {"type": "string", "description": "message bytes, base64"},
"signature_b64": {"type": "string", "description": "64-byte Ed25519 signature, base64"},
"public_key_b64": {"type": "string", "description": "32-byte raw Ed25519 public key, base64"},
"context": {"type": "string", "description": "free-text label recorded in the ledger"},
},
"required": ["payload_b64", "signature_b64", "public_key_b64"],
"additionalProperties": False,
},
"annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True},
},
{
"name": "request_signature",
"description": "Outbound signing with intent binding, spending policy, and the quorum firewall. Provide intent.purpose (recorded as WHY) and the payload; amount/counterparty feed the policy engine when rules exist; signer 'airgap' parks the request for the gap device (resend with the returned request_id after the device answers). The produced signature is quorum-verified before release and quarantined if it fails.",
"inputSchema": {
"type": "object",
"properties": {
"payload_b64": {"type": "string", "description": "message bytes to sign, base64"},
"purpose": {"type": "string", "description": "why this signature is requested (recorded in the ledger)"},
"identity": {"type": "string", "description": "wallet identity name (default: warden)"},
"amount": {"type": "number", "description": "policy units for spending rules (required when the policy has amount ceilings)"},
"counterparty": {"type": "string", "description": "who this benefits (required when the policy has counterparty lists)"},
"signer": {"type": "string", "enum": ["local", "airgap"], "description": "signing backend (default local)"},
"request_id": {"type": "string", "description": "airgap request id when completing a parked request"},
},
"required": ["payload_b64", "purpose"],
"additionalProperties": False,
},
"annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": False},
},
{
"name": "airgap_pending",
"description": "List parked airgap signing requests (outbox) and whether the device has answered (inbox). Use the request_id with request_signature to complete one.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
"annotations": {"readOnlyHint": True},
},
{
"name": "custody_card",
"description": "The self-proving business card: quorum membership with embedded transparency-log inclusion proofs a counterparty can recompute, signing provenance, honesty ledger. No trust required.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
"annotations": {"readOnlyHint": True},
},
{
"name": "posture_challenge",
"description": "Proof-of-posture: send a nonce (8..128 chars), receive a signed posture attestation whose signature passed the outbound firewall, with the full quorum trail attached. An auditable heartbeat.",
"inputSchema": {
"type": "object",
"properties": {"nonce": {"type": "string"}},
"required": ["nonce"],
"additionalProperties": False,
},
"annotations": {"readOnlyHint": False, "destructiveHint": False},
},
{
"name": "list_incidents",
"description": "Quorum divergences and firewall quarantines recorded by this wallet, newest-first, with severities and trails.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
"annotations": {"readOnlyHint": True},
},
{
"name": "explain_refusal",
"description": "Fetch a previously issued refusal receipt by index, or the latest. Returns the machine-actionable receipt (code, missing, remediation, signature).",
"inputSchema": {
"type": "object",
"properties": {"index": {"type": "integer", "description": "receipt number; omit for latest"}},
"additionalProperties": False,
},
"annotations": {"readOnlyHint": True},
},
]
class WalletMCP:
def __init__(
self,
wallet_dir: str | Path,
log_url: str = "https://ltl.zkdefi.org",
state_dir: str | Path | None = None,
) -> None:
self.wallet = Wallet(wallet_dir, state_dir=state_dir)
self.log_url = log_url
self.limiter = _RateLimiter()
self.handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"wallet_status": self._wallet_status,
"verify_inbound": self._verify_inbound,
"request_signature": self._request_signature,
"airgap_pending": self._airgap_pending,
"custody_card": self._custody_card,
"posture_challenge": self._posture_challenge,
"list_incidents": self._list_incidents,
"explain_refusal": self._explain_refusal,
}
# -- tool implementations ------------------------------------------------
def _wallet_status(self, _: dict[str, Any]) -> dict[str, Any]:
return self.wallet.posture()
def _verify_inbound(self, args: dict[str, Any]) -> dict[str, Any]:
payload = _b64_to_bytes("payload_b64", args.get("payload_b64"))
signature = _b64_to_bytes("signature_b64", args.get("signature_b64"))
public_key = _b64_to_bytes("public_key_b64", args.get("public_key_b64"))
if len(signature) != 64:
raise _ToolError("MALFORMED_INTENT", "signature must be 64 bytes", ["signature_b64"], "send a 64-byte Ed25519 signature")
if len(public_key) != 32:
raise _ToolError("MALFORMED_INTENT", "public key must be 32 bytes", ["public_key_b64"], "send a 32-byte raw Ed25519 key")
result = self.wallet.verify_inbound(payload, signature, public_key, context=str(args.get("context", "")))
return result.to_dict()
def _request_signature(self, args: dict[str, Any]) -> dict[str, Any]:
import hashlib
payload = _b64_to_bytes("payload_b64", args.get("payload_b64"))
purpose = args.get("purpose")
if not isinstance(purpose, str) or not purpose.strip():
raise _ToolError("MALFORMED_INTENT", "purpose is required", ["purpose"], "state why the signature is requested")
intent: dict[str, Any] = {"purpose": purpose, "payload_sha256": hashlib.sha256(payload).hexdigest()}
if args.get("amount") is not None:
intent["amount"] = args["amount"]
if args.get("counterparty") is not None:
intent["counterparty"] = args["counterparty"]
signer = None
if args.get("signer") == "airgap":
from .wallet import AirgapSigner
signer = AirgapSigner(self.wallet.airgap_dir)
result = self.wallet.request_signature(
intent,
payload,
signer=signer,
key_name=str(args.get("identity", "warden")),
request_id=args.get("request_id"),
)
if isinstance(result, Refusal):
raise _refusal_error(result)
return result
def _airgap_pending(self, _: dict[str, Any]) -> dict[str, Any]:
pending = []
outbox = self.wallet.airgap_dir / "outbox"
inbox = self.wallet.airgap_dir / "inbox"
for req in sorted(outbox.glob("*.request.json")):
request_id = req.name.removesuffix(".request.json")
body = json.loads(req.read_text(encoding="utf-8"))
pending.append({
"request_id": request_id,
"created_at": body.get("created_at"),
"payload_sha256": body.get("payload_sha256"),
"intent": body.get("intent"),
"device_answered": (inbox / f"{request_id}.response.json").exists(),
})
return {"pending": pending, "count": len(pending)}
def _custody_card(self, _: dict[str, Any]) -> dict[str, Any]:
return build_custody_card(self.wallet, self.log_url)
def _posture_challenge(self, args: dict[str, Any]) -> dict[str, Any]:
try:
result = posture_challenge(self.wallet, str(args.get("nonce", "")))
except ValueError as exc:
raise _ToolError("MALFORMED_INTENT", str(exc), ["nonce"], "send an 8..128 character nonce")
if isinstance(result, Refusal):
raise _refusal_error(result)
return result
def _list_incidents(self, _: dict[str, Any]) -> dict[str, Any]:
incidents = []
for path in sorted(self.wallet.incidents_dir.glob("*.json"), reverse=True):
incidents.append(json.loads(path.read_text(encoding="utf-8")))
return {"incidents": incidents, "count": len(incidents)}
def _explain_refusal(self, args: dict[str, Any]) -> dict[str, Any]:
receipts = sorted(self.wallet.receipts_dir.glob("*.json"))
if not receipts:
raise _ToolError("EVIDENCE_REQUIRED", "no refusal receipts issued yet", [], "there is nothing to explain")
idx = args.get("index")
if idx is None:
path = receipts[-1]
else:
match = [p for p in receipts if p.stem == f"{int(idx):04d}"]
if not match:
raise _ToolError("EVIDENCE_REQUIRED", f"no receipt {idx}", [], f"valid indices 0..{len(receipts)-1}")
path = match[0]
return json.loads(path.read_text(encoding="utf-8"))
# -- JSON-RPC plumbing ---------------------------------------------------
def handle(self, message: dict[str, Any]) -> dict[str, Any] | None:
method = message.get("method")
msg_id = message.get("id")
if method == "initialize":
return self._ok(msg_id, {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": SERVER_INFO,
})
if method in ("notifications/initialized", "initialized"):
return None
if method == "tools/list":
return self._ok(msg_id, {"tools": TOOLS})
if method == "tools/call":
params = message.get("params") or {}
name = params.get("name")
args = params.get("arguments") or {}
handler = self.handlers.get(name)
if handler is None:
return self._err(msg_id, -32602, f"unknown tool {name}")
try:
self.limiter.check(_TOOL_CATEGORY.get(name, "custody"))
payload = handler(args)
return self._ok(msg_id, {
"content": [{"type": "text", "text": json.dumps(payload, indent=2, sort_keys=True)}],
"structuredContent": payload,
"isError": False,
})
except _ToolError as exc:
return self._ok(msg_id, {
"content": [{"type": "text", "text": json.dumps(exc.payload, indent=2, sort_keys=True)}],
"structuredContent": exc.payload,
"isError": True,
})
except Exception as exc: # noqa: BLE001 - never crash the server
err = {"code": "INTERNAL", "reason": f"{type(exc).__name__}: {exc}", "missing": [], "remediation": "inspect the wallet directory"}
return self._ok(msg_id, {
"content": [{"type": "text", "text": json.dumps(err, indent=2, sort_keys=True)}],
"structuredContent": err,
"isError": True,
})
if msg_id is None:
return None
return self._err(msg_id, -32601, f"method not found: {method}")
@staticmethod
def _ok(msg_id: Any, result: dict[str, Any]) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
@staticmethod
def _err(msg_id: Any, code: int, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
def serve_stdio(self, stdin: Any = None, stdout: Any = None) -> None:
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
for line in stdin:
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
response = self.handle(message)
if response is not None:
stdout.write(json.dumps(response) + "\n")
stdout.flush()