mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-04 20:03:40 +00:00
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>
379 lines
17 KiB
Python
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()
|