feat: add ACT+ECT over MCP demo with LangGraph agent
End-to-end PoC demonstrating Agent Context Token authorization and Execution Context Token accountability over MCP tool calls, using a LangGraph agent with ES256-signed JWT tokens and DAG verification.
This commit is contained in:
3
demo/act-ect-mcp/src/poc/__init__.py
Normal file
3
demo/act-ect-mcp/src/poc/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""ACT + ECT + MCP + LangGraph end-to-end PoC."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
447
demo/act-ect-mcp/src/poc/agent.py
Normal file
447
demo/act-ect-mcp/src/poc/agent.py
Normal file
@@ -0,0 +1,447 @@
|
||||
"""LangGraph ReAct agent that calls MCP tools with ACT + ECT on every request.
|
||||
|
||||
Flow per run
|
||||
------------
|
||||
1. ``mint_mandate`` — user issues a Phase 1 ACT mandate that authorises the
|
||||
agent to use ``mcp.search``, ``mcp.summarize``, plus session-level actions.
|
||||
|
||||
2. ``MultiServerMCPClient`` opens a streamable-HTTP session to the MCP
|
||||
server. The session's ``httpx.AsyncClient`` has event hooks installed
|
||||
(``_install_ect_hooks``) that, on every outgoing POST to /mcp:
|
||||
|
||||
* build an ECT over the request body (inp_hash),
|
||||
* sign the request per RFC 9421 with ``wimse-aud=mcp-server``,
|
||||
* attach ``Authorization: Bearer <ACT>``, ``Wimse-ECT: <ect>``,
|
||||
``Content-Digest``, ``Signature-Input`` and ``Signature``.
|
||||
|
||||
Each ECT's ``pred`` chains to the mandate plus all prior tool-call ECTs
|
||||
in this run, so the ECT DAG captures the per-tool-call ordering.
|
||||
|
||||
3. ``create_react_agent`` runs a LangGraph ReAct loop with ChatOllama; the
|
||||
LLM decides when/what to call. The token plumbing is transparent to
|
||||
the model.
|
||||
|
||||
4. After the agent finishes its response, a single Phase 2 ACT execution
|
||||
record is minted that summarises the run (ACT §3.2: one mandate → one
|
||||
record; jti preserved). The record's ``inp_hash`` covers the task
|
||||
purpose and ``out_hash`` covers the final assistant message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langchain_ollama import ChatOllama
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
from act.crypto import b64url_sha256
|
||||
|
||||
from .http_sig import SignedRequest, content_digest, sign_request
|
||||
from .keys import Identity, load_identities
|
||||
from .tokens import (
|
||||
MintedMandate,
|
||||
MintedRecord,
|
||||
MintedECT,
|
||||
exec_act_for_rpc_method,
|
||||
mint_ect,
|
||||
mint_exec_record,
|
||||
mint_mandate,
|
||||
)
|
||||
|
||||
|
||||
LOG = logging.getLogger("poc.agent")
|
||||
|
||||
SERVER_IDENTITY_NAME = "mcp-server"
|
||||
|
||||
|
||||
# ---- Session ledger ---------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerEntry:
|
||||
kind: str # "mandate" | "ect" | "record"
|
||||
compact: str
|
||||
jti: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"kind": self.kind,
|
||||
"jti": self.jti,
|
||||
"compact": self.compact,
|
||||
"metadata": self.metadata,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionLedger:
|
||||
"""Mutable per-run state: mandate + growing chain of ECT tool invocations.
|
||||
|
||||
The ECT ``pred`` set grows with each successful tool call, giving a
|
||||
DAG of execution contexts. There is exactly one ACT Phase 2 record per
|
||||
run (minted at the end), whose jti equals the mandate jti per ACT §3.2.
|
||||
"""
|
||||
path: Path
|
||||
mandate: MintedMandate
|
||||
tool_ects: list[MintedECT] = field(default_factory=list)
|
||||
final_record: MintedRecord | None = None
|
||||
|
||||
def write_entry(self, entry: LedgerEntry) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(entry.to_json() + "\n")
|
||||
|
||||
def tool_ect_pred(self) -> list[str]:
|
||||
"""pred list for the *next* tool-call ECT: mandate + prior tool ECTs."""
|
||||
return [self.mandate.mandate.jti] + [e.payload.jti for e in self.tool_ects]
|
||||
|
||||
|
||||
# ---- httpx event hooks ------------------------------------------------------
|
||||
|
||||
|
||||
def _rpc_method_and_tool(body: bytes) -> tuple[str | None, str | None]:
|
||||
"""Sniff a JSON-RPC request body for (method, tool_name)."""
|
||||
try:
|
||||
obj = json.loads(body.decode("utf-8"))
|
||||
except Exception:
|
||||
return None, None
|
||||
if not isinstance(obj, dict):
|
||||
return None, None
|
||||
method = obj.get("method")
|
||||
if not isinstance(method, str):
|
||||
return None, None
|
||||
tool_name = None
|
||||
if method == "tools/call":
|
||||
params = obj.get("params") or {}
|
||||
name = params.get("name") if isinstance(params, dict) else None
|
||||
if isinstance(name, str):
|
||||
tool_name = name
|
||||
return method, tool_name
|
||||
|
||||
|
||||
def _install_ect_hooks(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
agent: Identity,
|
||||
audience: str,
|
||||
ledger: SessionLedger,
|
||||
mcp_path: str = "/mcp",
|
||||
) -> None:
|
||||
"""Attach request/response event hooks that inject ACT+ECT+sig headers."""
|
||||
state_key = "_poc_ect_state"
|
||||
|
||||
async def on_request(request: httpx.Request) -> None:
|
||||
if not request.url.path.endswith(mcp_path):
|
||||
return
|
||||
|
||||
# httpx may have already serialized body into request.content.
|
||||
body = request.content or b""
|
||||
method, tool_name = _rpc_method_and_tool(body)
|
||||
if method is None:
|
||||
# Not JSON-RPC — still attach mandate so middleware can 403
|
||||
# rather than 401, but skip ECT/record minting. The PoC never
|
||||
# triggers this path; keep it permissive to ease debugging.
|
||||
request.headers["authorization"] = f"Bearer {ledger.mandate.compact}"
|
||||
return
|
||||
try:
|
||||
exec_act = exec_act_for_rpc_method(method, tool_name)
|
||||
except ValueError:
|
||||
LOG.warning("unknown tool in tools/call: %r", tool_name)
|
||||
return
|
||||
|
||||
# Session-setup calls (initialize, tools/list, ping, …) don't grow
|
||||
# the tool-call DAG — they point only at the mandate. Tool-call
|
||||
# ECTs chain off the mandate plus every prior tool-call ECT.
|
||||
is_tool_call = method == "tools/call"
|
||||
if is_tool_call:
|
||||
pred_jtis = ledger.tool_ect_pred()
|
||||
else:
|
||||
pred_jtis = [ledger.mandate.mandate.jti]
|
||||
ect = mint_ect(
|
||||
agent=agent,
|
||||
audience=audience,
|
||||
exec_act=exec_act,
|
||||
pred_jtis=pred_jtis,
|
||||
inp_body=body,
|
||||
)
|
||||
|
||||
signed: SignedRequest = sign_request(
|
||||
method=request.method,
|
||||
target_uri=str(request.url),
|
||||
body=body,
|
||||
wimse_ect=ect.compact,
|
||||
wimse_aud=audience,
|
||||
keyid=agent.kid,
|
||||
private_key=agent.private_key,
|
||||
)
|
||||
|
||||
request.headers["authorization"] = f"Bearer {ledger.mandate.compact}"
|
||||
request.headers["wimse-ect"] = ect.compact
|
||||
request.headers["content-digest"] = signed.content_digest
|
||||
request.headers["signature-input"] = signed.signature_input
|
||||
request.headers["signature"] = signed.signature
|
||||
|
||||
# Stash so response hook can mint the exec record correlating the
|
||||
# HTTP exchange with the ECT we just sent.
|
||||
setattr(request, state_key, {
|
||||
"ect": ect,
|
||||
"exec_act": exec_act,
|
||||
"method": method,
|
||||
"tool_name": tool_name,
|
||||
"inp_hash": b64url_sha256(body),
|
||||
"pred_jtis": pred_jtis,
|
||||
"request_body": body,
|
||||
})
|
||||
|
||||
async def on_response(response: httpx.Response) -> None:
|
||||
request = response.request
|
||||
st = getattr(request, state_key, None)
|
||||
if not st:
|
||||
return
|
||||
method: str = st["method"]
|
||||
ect = st["ect"]
|
||||
if method == "tools/call":
|
||||
ledger.tool_ects.append(ect)
|
||||
ledger.write_entry(
|
||||
LedgerEntry(
|
||||
kind="ect",
|
||||
compact=ect.compact,
|
||||
jti=ect.payload.jti,
|
||||
metadata={
|
||||
"method": method,
|
||||
"tool_name": st["tool_name"],
|
||||
"exec_act": st["exec_act"],
|
||||
"pred": list(ect.payload.pred),
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
ledger.write_entry(
|
||||
LedgerEntry(
|
||||
kind="ect",
|
||||
compact=ect.compact,
|
||||
jti=ect.payload.jti,
|
||||
metadata={
|
||||
"method": method,
|
||||
"exec_act": st["exec_act"],
|
||||
"session_only": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
client.event_hooks["request"].append(on_request)
|
||||
client.event_hooks["response"].append(on_response)
|
||||
|
||||
|
||||
# ---- MCP client factory -----------------------------------------------------
|
||||
|
||||
|
||||
def make_httpx_client_factory(agent: Identity, audience: str, ledger: SessionLedger):
|
||||
"""Return an httpx_client_factory that installs our hooks on each client."""
|
||||
from mcp.shared._httpx_utils import (
|
||||
MCP_DEFAULT_SSE_READ_TIMEOUT,
|
||||
MCP_DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
kwargs: dict[str, Any] = {"follow_redirects": True}
|
||||
if timeout is None:
|
||||
kwargs["timeout"] = httpx.Timeout(
|
||||
MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT
|
||||
)
|
||||
else:
|
||||
kwargs["timeout"] = timeout
|
||||
if headers is not None:
|
||||
kwargs["headers"] = headers
|
||||
if auth is not None:
|
||||
kwargs["auth"] = auth
|
||||
client = httpx.AsyncClient(**kwargs)
|
||||
_install_ect_hooks(client, agent=agent, audience=audience, ledger=ledger)
|
||||
return client
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
# ---- Run an agent turn ------------------------------------------------------
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_mcp_client(
|
||||
*, agent: Identity, audience: str, ledger: SessionLedger, url: str
|
||||
) -> AsyncIterator[MultiServerMCPClient]:
|
||||
factory = make_httpx_client_factory(agent, audience, ledger)
|
||||
client = MultiServerMCPClient(
|
||||
{
|
||||
"poc": {
|
||||
"transport": "streamable_http",
|
||||
"url": url,
|
||||
"httpx_client_factory": factory,
|
||||
}
|
||||
}
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
# MultiServerMCPClient does not expose an explicit close in 0.2.x;
|
||||
# sessions are closed per get_tools() call. Nothing to do here.
|
||||
pass
|
||||
|
||||
|
||||
async def run_once(
|
||||
*,
|
||||
purpose: str,
|
||||
model: str,
|
||||
mcp_url: str,
|
||||
keys_dir: str,
|
||||
ledger_path: str,
|
||||
ollama_host: str | None,
|
||||
) -> dict[str, Any]:
|
||||
identities = load_identities(keys_dir)
|
||||
user = identities["user"]
|
||||
agent = identities["agent"]
|
||||
|
||||
mandate = mint_mandate(
|
||||
user=user,
|
||||
agent=agent,
|
||||
audience=SERVER_IDENTITY_NAME,
|
||||
purpose=purpose,
|
||||
)
|
||||
ledger = SessionLedger(path=Path(ledger_path), mandate=mandate)
|
||||
ledger.write_entry(
|
||||
LedgerEntry(
|
||||
kind="mandate",
|
||||
compact=mandate.compact,
|
||||
jti=mandate.mandate.jti,
|
||||
metadata={
|
||||
"iss": mandate.mandate.iss,
|
||||
"sub": mandate.mandate.sub,
|
||||
"aud": mandate.mandate.aud,
|
||||
"task": mandate.mandate.task.to_dict(),
|
||||
"cap": [c.to_dict() for c in mandate.mandate.cap],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async with open_mcp_client(
|
||||
agent=agent, audience=SERVER_IDENTITY_NAME, ledger=ledger, url=mcp_url
|
||||
) as client:
|
||||
tools = await client.get_tools()
|
||||
LOG.info("loaded %d MCP tools: %s", len(tools), [t.name for t in tools])
|
||||
|
||||
llm_kwargs: dict[str, Any] = {"model": model, "temperature": 0.0}
|
||||
if ollama_host:
|
||||
llm_kwargs["base_url"] = ollama_host
|
||||
llm = ChatOllama(**llm_kwargs)
|
||||
|
||||
graph = create_react_agent(llm, tools)
|
||||
|
||||
system = SystemMessage(
|
||||
content=(
|
||||
"You are a research assistant with access to two tools: "
|
||||
"search(query) and summarize(text). "
|
||||
"For the user's task, first call search to gather material, "
|
||||
"then call summarize on the joined results. "
|
||||
"After the summary, reply with the summary and stop."
|
||||
)
|
||||
)
|
||||
human = HumanMessage(content=purpose)
|
||||
result = await graph.ainvoke({"messages": [system, human]})
|
||||
|
||||
final_msg = result["messages"][-1]
|
||||
final_text = getattr(final_msg, "content", str(final_msg))
|
||||
if isinstance(final_text, list):
|
||||
final_text = json.dumps(final_text, sort_keys=True)
|
||||
|
||||
# ACT §3.2: one mandate → one Phase 2 record (jti preserved). The
|
||||
# record summarises the whole invocation; per-tool-call DAG structure
|
||||
# lives in the ECTs we already logged.
|
||||
final_record = mint_exec_record(
|
||||
agent=agent,
|
||||
mandate=mandate.mandate,
|
||||
exec_act="mcp.summarize", # terminal exec_act; picked from cap
|
||||
pred_jtis=[], # root task within this run's ACT view
|
||||
inp_body=purpose.encode("utf-8"),
|
||||
out_body=final_text.encode("utf-8"),
|
||||
)
|
||||
ledger.final_record = final_record
|
||||
ledger.write_entry(
|
||||
LedgerEntry(
|
||||
kind="record",
|
||||
compact=final_record.compact,
|
||||
jti=final_record.record.jti,
|
||||
metadata={
|
||||
"exec_act": final_record.record.exec_act,
|
||||
"status": final_record.record.status,
|
||||
"pred": list(final_record.record.pred),
|
||||
"inp_hash": final_record.record.inp_hash,
|
||||
"out_hash": final_record.record.out_hash,
|
||||
"n_tool_ects": len(ledger.tool_ects),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"mandate_jti": mandate.mandate.jti,
|
||||
"record_jti": final_record.record.jti,
|
||||
"tool_ects": [e.payload.jti for e in ledger.tool_ects],
|
||||
"final_message": final_text,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("POC_LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = argparse.ArgumentParser(description="ACT/ECT MCP PoC agent")
|
||||
parser.add_argument(
|
||||
"--purpose",
|
||||
default="Summarise recent research on agent authorization tokens.",
|
||||
help="High-level task the mandate authorises.",
|
||||
)
|
||||
parser.add_argument("--model", default=os.environ.get("POC_MODEL", "qwen3:8b"))
|
||||
parser.add_argument(
|
||||
"--mcp-url", default=os.environ.get("POC_MCP_URL", "http://127.0.0.1:8765/mcp")
|
||||
)
|
||||
parser.add_argument("--keys-dir", default=os.environ.get("POC_KEYS_DIR", "keys"))
|
||||
parser.add_argument(
|
||||
"--ledger", default=os.environ.get("POC_LEDGER", "keys/ledger.jsonl")
|
||||
)
|
||||
parser.add_argument("--ollama-host", default=os.environ.get("OLLAMA_HOST"))
|
||||
args = parser.parse_args()
|
||||
|
||||
summary = asyncio.run(
|
||||
run_once(
|
||||
purpose=args.purpose,
|
||||
model=args.model,
|
||||
mcp_url=args.mcp_url,
|
||||
keys_dir=args.keys_dir,
|
||||
ledger_path=args.ledger,
|
||||
ollama_host=args.ollama_host,
|
||||
)
|
||||
)
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
238
demo/act-ect-mcp/src/poc/http_sig.py
Normal file
238
demo/act-ect-mcp/src/poc/http_sig.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Minimal RFC 9421 HTTP Message Signatures for the PoC.
|
||||
|
||||
Covers just enough of RFC 9421 + draft-ietf-wimse-http-signature-03 to
|
||||
bind an ECT-bearing request to its method, target URI, body digest, and
|
||||
the Wimse-ECT header itself. Not a general-purpose implementation — the
|
||||
signed-component serialization follows RFC 9421 §2.3 for this fixed set
|
||||
of components only.
|
||||
|
||||
Signature metadata parameters per draft-ietf-wimse-http-signature-03:
|
||||
keyid — kid of the signing workload
|
||||
alg — "ecdsa-p256-sha256"
|
||||
created — NumericDate seconds
|
||||
wimse-aud — target audience workload identity (new in -03, replaces
|
||||
the removed Wimse-Audience HTTP header)
|
||||
|
||||
Format:
|
||||
Signature-Input: sig1=(\"@method\" \"@target-uri\" \"content-digest\" \
|
||||
\"wimse-ect\");created=...;keyid=\"...\";\
|
||||
alg=\"ecdsa-p256-sha256\";wimse-aud=\"...\"
|
||||
Signature: sig1=:<base64>:
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
EllipticCurvePrivateKey,
|
||||
EllipticCurvePublicKey,
|
||||
)
|
||||
|
||||
from act.crypto import sign as act_sign, verify as act_verify
|
||||
from act.errors import ACTSignatureError
|
||||
|
||||
|
||||
COVERED_COMPONENTS: tuple[str, ...] = (
|
||||
"@method",
|
||||
"@target-uri",
|
||||
"content-digest",
|
||||
"wimse-ect",
|
||||
)
|
||||
|
||||
SIG_ALG = "ecdsa-p256-sha256"
|
||||
|
||||
|
||||
def content_digest(body: bytes) -> str:
|
||||
"""RFC 9530 Content-Digest header value using sha-256."""
|
||||
digest = hashlib.sha256(body).digest()
|
||||
return "sha-256=:" + base64.b64encode(digest).decode("ascii") + ":"
|
||||
|
||||
|
||||
def _serialize_components(
|
||||
*,
|
||||
method: str,
|
||||
target_uri: str,
|
||||
content_digest_hdr: str,
|
||||
wimse_ect_hdr: str,
|
||||
params: str,
|
||||
) -> bytes:
|
||||
"""RFC 9421 §2.3 signature base for the fixed PoC component set."""
|
||||
lines = [
|
||||
f'"@method": {method.upper()}',
|
||||
f'"@target-uri": {target_uri}',
|
||||
f'"content-digest": {content_digest_hdr}',
|
||||
f'"wimse-ect": {wimse_ect_hdr}',
|
||||
f'"@signature-params": {params}',
|
||||
]
|
||||
return "\n".join(lines).encode("ascii")
|
||||
|
||||
|
||||
def _signature_params(
|
||||
*,
|
||||
created: int,
|
||||
keyid: str,
|
||||
wimse_aud: str,
|
||||
) -> str:
|
||||
"""Render the signature-params string (quoted inner-list + params)."""
|
||||
components = " ".join(f'"{c}"' for c in COVERED_COMPONENTS)
|
||||
return (
|
||||
f"({components})"
|
||||
f";created={created}"
|
||||
f';keyid="{keyid}"'
|
||||
f';alg="{SIG_ALG}"'
|
||||
f';wimse-aud="{wimse_aud}"'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignedRequest:
|
||||
content_digest: str
|
||||
signature_input: str
|
||||
signature: str
|
||||
|
||||
|
||||
def sign_request(
|
||||
*,
|
||||
method: str,
|
||||
target_uri: str,
|
||||
body: bytes,
|
||||
wimse_ect: str,
|
||||
wimse_aud: str,
|
||||
keyid: str,
|
||||
private_key: EllipticCurvePrivateKey,
|
||||
created: int | None = None,
|
||||
) -> SignedRequest:
|
||||
"""Produce the three headers needed to send a signed PoC request."""
|
||||
created = int(created if created is not None else time.time())
|
||||
cd = content_digest(body)
|
||||
params = _signature_params(created=created, keyid=keyid, wimse_aud=wimse_aud)
|
||||
base = _serialize_components(
|
||||
method=method,
|
||||
target_uri=target_uri,
|
||||
content_digest_hdr=cd,
|
||||
wimse_ect_hdr=wimse_ect,
|
||||
params=params,
|
||||
)
|
||||
sig = act_sign(private_key, base)
|
||||
sig_b64 = base64.b64encode(sig).decode("ascii")
|
||||
return SignedRequest(
|
||||
content_digest=cd,
|
||||
signature_input=f"sig1={params}",
|
||||
signature=f"sig1=:{sig_b64}:",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedSignature:
|
||||
covered: tuple[str, ...]
|
||||
created: int
|
||||
keyid: str
|
||||
alg: str
|
||||
wimse_aud: str
|
||||
raw_params: str
|
||||
signature_b64: str
|
||||
|
||||
|
||||
def _parse_signature_input(value: str) -> ParsedSignature:
|
||||
"""Parse 'sig1=(...);created=...;keyid="...";alg="...";wimse-aud="..."'."""
|
||||
if "=" not in value:
|
||||
raise ValueError("signature-input: missing label")
|
||||
_label, _, inner = value.partition("=")
|
||||
# inner: '("a" "b" ...);created=...;keyid="...";...'
|
||||
if not inner.startswith("("):
|
||||
raise ValueError("signature-input: missing covered components list")
|
||||
close = inner.index(")")
|
||||
components_raw = inner[1:close]
|
||||
covered = tuple(
|
||||
part.strip().strip('"') for part in components_raw.split() if part.strip()
|
||||
)
|
||||
rest = inner[close + 1 :]
|
||||
params: dict[str, str] = {}
|
||||
for part in rest.split(";"):
|
||||
part = part.strip()
|
||||
if not part or "=" not in part:
|
||||
continue
|
||||
k, _, v = part.partition("=")
|
||||
params[k.strip()] = v.strip().strip('"')
|
||||
return ParsedSignature(
|
||||
covered=covered,
|
||||
created=int(params["created"]),
|
||||
keyid=params["keyid"],
|
||||
alg=params["alg"],
|
||||
wimse_aud=params["wimse-aud"],
|
||||
raw_params=inner, # full params string (for sig base reconstruction)
|
||||
signature_b64="",
|
||||
)
|
||||
|
||||
|
||||
def _parse_signature(value: str) -> str:
|
||||
"""Parse 'sig1=:<base64>:' → base64 string."""
|
||||
if "=" not in value:
|
||||
raise ValueError("signature: missing label")
|
||||
_label, _, inner = value.partition("=")
|
||||
inner = inner.strip()
|
||||
if not (inner.startswith(":") and inner.endswith(":")):
|
||||
raise ValueError("signature: expected byte-sequence form :...:" )
|
||||
return inner[1:-1]
|
||||
|
||||
|
||||
def verify_request(
|
||||
*,
|
||||
method: str,
|
||||
target_uri: str,
|
||||
body: bytes,
|
||||
wimse_ect_header: str,
|
||||
content_digest_header: str,
|
||||
signature_input_header: str,
|
||||
signature_header: str,
|
||||
expected_audience: str,
|
||||
public_key: EllipticCurvePublicKey,
|
||||
max_age_seconds: int = 300,
|
||||
now: int | None = None,
|
||||
) -> ParsedSignature:
|
||||
"""Verify the signature covers the expected components and matches.
|
||||
|
||||
Returns the parsed signature metadata (keyid, alg, wimse-aud, created)
|
||||
so the caller can cross-check against ECT/ACT claims.
|
||||
"""
|
||||
parsed = _parse_signature_input(signature_input_header)
|
||||
if parsed.covered != COVERED_COMPONENTS:
|
||||
raise ACTSignatureError(
|
||||
f"signed components {parsed.covered!r} differ from expected "
|
||||
f"{COVERED_COMPONENTS!r}"
|
||||
)
|
||||
if parsed.alg != SIG_ALG:
|
||||
raise ACTSignatureError(f"unexpected alg {parsed.alg!r}")
|
||||
if parsed.wimse_aud != expected_audience:
|
||||
raise ACTSignatureError(
|
||||
f"wimse-aud {parsed.wimse_aud!r} != expected {expected_audience!r}"
|
||||
)
|
||||
|
||||
current = int(now if now is not None else time.time())
|
||||
if current - parsed.created > max_age_seconds:
|
||||
raise ACTSignatureError(
|
||||
f"signature too old: created={parsed.created}, now={current}"
|
||||
)
|
||||
|
||||
expected_digest = content_digest(body)
|
||||
if content_digest_header != expected_digest:
|
||||
raise ACTSignatureError("content-digest does not match body")
|
||||
|
||||
sig_b64 = _parse_signature(signature_header)
|
||||
sig = base64.b64decode(sig_b64)
|
||||
base = _serialize_components(
|
||||
method=method,
|
||||
target_uri=target_uri,
|
||||
content_digest_hdr=content_digest_header,
|
||||
wimse_ect_hdr=wimse_ect_header,
|
||||
params=parsed.raw_params,
|
||||
)
|
||||
act_verify(public_key, sig, base) # raises ACTSignatureError on bad sig
|
||||
parsed.signature_b64 = sig_b64
|
||||
return parsed
|
||||
97
demo/act-ect-mcp/src/poc/keys.py
Normal file
97
demo/act-ect-mcp/src/poc/keys.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Key material for the three PoC identities.
|
||||
|
||||
The PoC uses three ES256 (P-256) keys — the common algorithm for both
|
||||
ACT and ECT per draft-nennemann-act-01 §5 and draft-nennemann-wimse-ect-01 §5.
|
||||
|
||||
Identities:
|
||||
user — issues the ACT mandate (iss in Phase 1)
|
||||
agent — subject of the mandate, signs Phase 2 record, signs ECT on
|
||||
every MCP tool call (sub in ACT, iss in ECT)
|
||||
mcp-server — audience / verifier (aud in both ACT and ECT)
|
||||
|
||||
Keys are written to ``keys/`` as PEM files on first run; subsequent runs
|
||||
load them. This mimics pre-shared-key deployment per ACT §5.2 Tier 1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
EllipticCurvePrivateKey,
|
||||
EllipticCurvePublicKey,
|
||||
)
|
||||
|
||||
from act.crypto import KeyRegistry, generate_p256_keypair
|
||||
|
||||
|
||||
IDENTITIES = ("user", "agent", "mcp-server")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Identity:
|
||||
name: str
|
||||
kid: str
|
||||
private_key: EllipticCurvePrivateKey
|
||||
public_key: EllipticCurvePublicKey
|
||||
|
||||
|
||||
def _pem_paths(keys_dir: Path, name: str) -> tuple[Path, Path]:
|
||||
return keys_dir / f"{name}.priv.pem", keys_dir / f"{name}.pub.pem"
|
||||
|
||||
|
||||
def _load_or_generate(keys_dir: Path, name: str) -> Identity:
|
||||
priv_path, pub_path = _pem_paths(keys_dir, name)
|
||||
if priv_path.exists() and pub_path.exists():
|
||||
priv_bytes = priv_path.read_bytes()
|
||||
priv = serialization.load_pem_private_key(priv_bytes, password=None)
|
||||
assert isinstance(priv, EllipticCurvePrivateKey), (
|
||||
f"{name}.priv.pem is not a P-256 private key"
|
||||
)
|
||||
pub = priv.public_key()
|
||||
else:
|
||||
priv, pub = generate_p256_keypair()
|
||||
keys_dir.mkdir(parents=True, exist_ok=True)
|
||||
priv_path.write_bytes(
|
||||
priv.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
pub_path.write_bytes(
|
||||
pub.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
)
|
||||
kid = f"kid:{name}:v1"
|
||||
return Identity(name=name, kid=kid, private_key=priv, public_key=pub)
|
||||
|
||||
|
||||
def load_identities(keys_dir: str | Path = "keys") -> dict[str, Identity]:
|
||||
"""Load all three PoC identities, generating key material if missing."""
|
||||
keys_dir = Path(keys_dir)
|
||||
return {name: _load_or_generate(keys_dir, name) for name in IDENTITIES}
|
||||
|
||||
|
||||
def build_key_registry(identities: dict[str, Identity]) -> KeyRegistry:
|
||||
"""Assemble an ACT KeyRegistry with every identity's public key."""
|
||||
reg = KeyRegistry()
|
||||
for ident in identities.values():
|
||||
reg.register(ident.kid, ident.public_key)
|
||||
return reg
|
||||
|
||||
|
||||
def build_ect_key_resolver(identities: dict[str, Identity]):
|
||||
"""Return an ECT KeyResolver callable that maps kid → public key."""
|
||||
kid_to_pub: dict[str, EllipticCurvePublicKey] = {
|
||||
ident.kid: ident.public_key for ident in identities.values()
|
||||
}
|
||||
|
||||
def _resolve(kid: str) -> EllipticCurvePublicKey | None:
|
||||
return kid_to_pub.get(kid)
|
||||
|
||||
return _resolve
|
||||
300
demo/act-ect-mcp/src/poc/server.py
Normal file
300
demo/act-ect-mcp/src/poc/server.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""MCP server with ACT + ECT + HTTP-signature enforcement middleware.
|
||||
|
||||
The server exposes two tools via FastMCP streamable-HTTP:
|
||||
|
||||
search(query: str) — returns a list of fake hits (str[])
|
||||
summarize(text: str) — returns a short synthetic summary (str)
|
||||
|
||||
Every POST to /mcp goes through ``ActEctAuthMiddleware`` which:
|
||||
|
||||
1. parses ``Authorization: Bearer <act-mandate>`` and verifies the
|
||||
Phase 1 mandate (ACT §8.1);
|
||||
2. parses ``Wimse-ECT: <ect-compact>`` and verifies the ECT
|
||||
(draft-nennemann-wimse-ect-01 §7);
|
||||
3. verifies the RFC 9421 HTTP-signature over
|
||||
@method/@target-uri/content-digest/wimse-ect with
|
||||
wimse-aud=mcp-server (per draft-ietf-wimse-http-signature-03);
|
||||
4. cross-checks exec_act ∈ mandate.cap[].action, mandate.sub == ECT.iss,
|
||||
ECT.inp_hash == sha256(body).
|
||||
|
||||
On failure any check returns HTTP 401/403 before the request reaches
|
||||
FastMCP. Successful audits are appended to ``AUDIT_LOG_PATH`` so the
|
||||
verifier CLI can reconstruct the server's view of the DAG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from act.crypto import ACTKeyResolver, b64url_sha256
|
||||
from act.errors import ACTError, ACTSignatureError
|
||||
from act.verify import ACTVerifier
|
||||
|
||||
import ect as ect_pkg # noqa: F401 (ensures package import)
|
||||
from ect.verify import verify as ect_verify, VerifyOptions
|
||||
|
||||
from .http_sig import verify_request
|
||||
from .keys import Identity, build_ect_key_resolver, build_key_registry, load_identities
|
||||
|
||||
|
||||
LOG = logging.getLogger("poc.server")
|
||||
|
||||
AUDIT_LOG_PATH = Path(os.environ.get("POC_AUDIT_LOG", "keys/server-audit.jsonl"))
|
||||
KEYS_DIR = os.environ.get("POC_KEYS_DIR", "keys")
|
||||
SERVER_IDENTITY_NAME = "mcp-server"
|
||||
AGENT_IDENTITY_NAME = "agent"
|
||||
|
||||
|
||||
# ---- FastMCP tools ----------------------------------------------------------
|
||||
|
||||
|
||||
def _build_mcp() -> FastMCP:
|
||||
"""Fresh FastMCP instance with the two PoC tools registered.
|
||||
|
||||
A new instance per ``build_app`` call keeps ``StreamableHTTPSessionManager``
|
||||
usable in tests that start the Starlette lifespan multiple times (the
|
||||
session manager is a single-use object).
|
||||
"""
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
|
||||
mcp = FastMCP(
|
||||
"act-ect-poc",
|
||||
transport_security=TransportSecuritySettings(
|
||||
# Allow the test hostname ``testserver`` in addition to loopback.
|
||||
allowed_hosts=[
|
||||
"127.0.0.1:*", "localhost:*", "[::1]:*", "testserver",
|
||||
],
|
||||
allowed_origins=[
|
||||
"http://127.0.0.1:*", "http://localhost:*",
|
||||
"http://[::1]:*", "http://testserver",
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
def search(query: str) -> list[str]:
|
||||
"""Return three fake search hits for the query."""
|
||||
q = query.strip() or "empty"
|
||||
return [f"[{q}] result {i + 1}: lorem ipsum about {q}" for i in range(3)]
|
||||
|
||||
@mcp.tool()
|
||||
def summarize(text: str) -> str:
|
||||
"""Return a deterministic one-line summary of the text."""
|
||||
snippet = text.strip().replace("\n", " ")
|
||||
if len(snippet) > 120:
|
||||
snippet = snippet[:117] + "..."
|
||||
return f"Summary: {snippet}"
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
# ---- Auth middleware --------------------------------------------------------
|
||||
|
||||
|
||||
class ActEctAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Enforce ACT mandate + ECT + HTTP signature on every tool invocation."""
|
||||
|
||||
def __init__(self, app, identities: dict[str, Identity]) -> None:
|
||||
super().__init__(app)
|
||||
self._identities = identities
|
||||
server = identities[SERVER_IDENTITY_NAME]
|
||||
agent = identities[AGENT_IDENTITY_NAME]
|
||||
|
||||
registry = build_key_registry(identities)
|
||||
resolver = ACTKeyResolver(registry=registry)
|
||||
self._act_verifier = ACTVerifier(
|
||||
resolver,
|
||||
verifier_id=SERVER_IDENTITY_NAME,
|
||||
trusted_issuers={ident.name for ident in identities.values()},
|
||||
)
|
||||
self._ect_resolver = build_ect_key_resolver(identities)
|
||||
self._agent_public_key = agent.public_key
|
||||
self._server_name = server.name
|
||||
self._agent_name = agent.name
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Only enforce on the MCP endpoint; let everything else through.
|
||||
if request.url.path != "/mcp" or request.method.upper() != "POST":
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
auth_ctx = await self._authorize(request)
|
||||
except _AuthFailure as e:
|
||||
LOG.warning("auth rejected: %s", e.detail)
|
||||
return JSONResponse({"error": e.detail}, status_code=e.status)
|
||||
|
||||
# Pass the verified context downstream so a handler could read it.
|
||||
request.state.act_ect = auth_ctx
|
||||
response: Response = await call_next(request)
|
||||
_append_audit(AUDIT_LOG_PATH, auth_ctx)
|
||||
return response
|
||||
|
||||
async def _authorize(self, request: Request) -> dict[str, Any]:
|
||||
body = await request.body()
|
||||
|
||||
# 1. Authorization: Bearer <act-mandate-compact>
|
||||
auth_hdr = request.headers.get("authorization", "")
|
||||
if not auth_hdr.lower().startswith("bearer "):
|
||||
raise _AuthFailure(401, "missing Authorization: Bearer")
|
||||
act_compact = auth_hdr[len("bearer ") :].strip()
|
||||
|
||||
# 2. Wimse-ECT: <ect-compact>
|
||||
ect_compact = request.headers.get("wimse-ect", "").strip()
|
||||
if not ect_compact:
|
||||
raise _AuthFailure(401, "missing Wimse-ECT header")
|
||||
|
||||
# 3. RFC 9421 HTTP signature (over method/target/content-digest/wimse-ect)
|
||||
sig_input = request.headers.get("signature-input", "")
|
||||
sig = request.headers.get("signature", "")
|
||||
content_digest_hdr = request.headers.get("content-digest", "")
|
||||
if not (sig_input and sig and content_digest_hdr):
|
||||
raise _AuthFailure(
|
||||
401, "missing Signature / Signature-Input / Content-Digest"
|
||||
)
|
||||
|
||||
# Starlette's request.url gives us a URL; canonicalize target URI.
|
||||
# Use the path+query as the target since scheme/host differs behind
|
||||
# reverse proxies. For the PoC we match client and server on the
|
||||
# exact full URL the client signed.
|
||||
target_uri = str(request.url)
|
||||
|
||||
try:
|
||||
parsed_sig = verify_request(
|
||||
method=request.method,
|
||||
target_uri=target_uri,
|
||||
body=body,
|
||||
wimse_ect_header=ect_compact,
|
||||
content_digest_header=content_digest_hdr,
|
||||
signature_input_header=sig_input,
|
||||
signature_header=sig,
|
||||
expected_audience=self._server_name,
|
||||
public_key=self._agent_public_key,
|
||||
)
|
||||
except ACTSignatureError as e:
|
||||
raise _AuthFailure(401, f"http-signature failed: {e}") from e
|
||||
|
||||
# 4. ACT mandate
|
||||
try:
|
||||
mandate = self._act_verifier.verify_mandate(
|
||||
act_compact, check_sub=False
|
||||
)
|
||||
except ACTError as e:
|
||||
raise _AuthFailure(401, f"ACT mandate rejected: {e}") from e
|
||||
|
||||
if mandate.sub != self._agent_name:
|
||||
raise _AuthFailure(
|
||||
403, f"mandate.sub {mandate.sub!r} != agent {self._agent_name!r}"
|
||||
)
|
||||
|
||||
# 5. ECT
|
||||
try:
|
||||
ect_opts = VerifyOptions(
|
||||
verifier_id=self._server_name,
|
||||
resolve_key=self._ect_resolver,
|
||||
)
|
||||
parsed_ect = ect_verify(ect_compact, ect_opts)
|
||||
except Exception as e: # ECT refimpl raises ValueError subclasses
|
||||
raise _AuthFailure(401, f"ECT rejected: {e}") from e
|
||||
|
||||
if parsed_sig.keyid != parsed_ect.header.get("kid"):
|
||||
raise _AuthFailure(
|
||||
401,
|
||||
f"http-sig keyid {parsed_sig.keyid!r} != ect kid "
|
||||
f"{parsed_ect.header.get('kid')!r}",
|
||||
)
|
||||
|
||||
# 6. Cross-check inp_hash binds to this body
|
||||
body_hash = b64url_sha256(body)
|
||||
if parsed_ect.payload.inp_hash and parsed_ect.payload.inp_hash != body_hash:
|
||||
raise _AuthFailure(401, "ECT.inp_hash does not match request body")
|
||||
|
||||
# 7. exec_act must be authorised by mandate.cap
|
||||
cap_actions = {c.action for c in mandate.cap}
|
||||
if parsed_ect.payload.exec_act not in cap_actions:
|
||||
raise _AuthFailure(
|
||||
403,
|
||||
f"exec_act {parsed_ect.payload.exec_act!r} not in "
|
||||
f"mandate.cap {sorted(cap_actions)!r}",
|
||||
)
|
||||
|
||||
# 8. ECT issuer should equal mandate subject (the executing agent)
|
||||
if parsed_ect.payload.iss != mandate.sub:
|
||||
raise _AuthFailure(
|
||||
403,
|
||||
f"ECT.iss {parsed_ect.payload.iss!r} != mandate.sub "
|
||||
f"{mandate.sub!r}",
|
||||
)
|
||||
|
||||
return {
|
||||
"ts": int(time.time()),
|
||||
"mandate_jti": mandate.jti,
|
||||
"ect_jti": parsed_ect.payload.jti,
|
||||
"exec_act": parsed_ect.payload.exec_act,
|
||||
"pred": list(parsed_ect.payload.pred),
|
||||
"inp_hash": body_hash,
|
||||
"wimse_aud": parsed_sig.wimse_aud,
|
||||
"keyid": parsed_sig.keyid,
|
||||
}
|
||||
|
||||
|
||||
class _AuthFailure(Exception):
|
||||
def __init__(self, status: int, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.status = status
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _append_audit(path: Path, entry: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
||||
|
||||
|
||||
# ---- ASGI assembly ----------------------------------------------------------
|
||||
|
||||
|
||||
def build_app(identities: dict[str, Identity]):
|
||||
mcp = _build_mcp()
|
||||
app = mcp.streamable_http_app()
|
||||
app.add_middleware(ActEctAuthMiddleware, identities=identities)
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("POC_LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = argparse.ArgumentParser(description="ACT/ECT MCP PoC server")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--keys-dir", default=KEYS_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
identities = load_identities(args.keys_dir)
|
||||
app = build_app(identities)
|
||||
LOG.info(
|
||||
"serving MCP at http://%s:%d/mcp; audit=%s",
|
||||
args.host,
|
||||
args.port,
|
||||
AUDIT_LOG_PATH,
|
||||
)
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
197
demo/act-ect-mcp/src/poc/tokens.py
Normal file
197
demo/act-ect-mcp/src/poc/tokens.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Mint and verify ACT mandates/records and ECT payloads for the PoC.
|
||||
|
||||
Thin convenience wrappers around ietf-act and ietf-ect that fix the
|
||||
PoC's shape (one user issues to one agent, one MCP-server audience)
|
||||
while leaving all cryptography and claim validation to the refimpls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from act.crypto import b64url_sha256, sign as act_sign
|
||||
from act.token import (
|
||||
ACTMandate,
|
||||
ACTRecord,
|
||||
Capability,
|
||||
TaskClaim,
|
||||
encode_jws,
|
||||
)
|
||||
|
||||
import ect
|
||||
from ect.create import CreateOptions, create as ect_create
|
||||
from ect.types import Payload as ECTPayload
|
||||
|
||||
from .keys import Identity
|
||||
|
||||
|
||||
# Capability action names the agent may exercise on the MCP server.
|
||||
# mcp.session.* covers JSON-RPC plumbing (initialize, tools/list, ping, …)
|
||||
# that precedes real tool invocations.
|
||||
MCP_CAPS = (
|
||||
"mcp.session.initialize",
|
||||
"mcp.session.list_tools",
|
||||
"mcp.session.other",
|
||||
"mcp.search",
|
||||
"mcp.summarize",
|
||||
)
|
||||
|
||||
# Map tool-name → exec_act for real tool calls.
|
||||
TOOL_ACTION = {
|
||||
"search": "mcp.search",
|
||||
"summarize": "mcp.summarize",
|
||||
}
|
||||
|
||||
|
||||
def exec_act_for_rpc_method(method: str, tool_name: str | None = None) -> str:
|
||||
"""Return the ACT/ECT exec_act string for a JSON-RPC call.
|
||||
|
||||
Rules:
|
||||
* ``tools/call`` dispatches to ``TOOL_ACTION[tool_name]``
|
||||
* ``initialize`` and ``tools/list`` have dedicated ``mcp.session.*`` actions
|
||||
* anything else collapses to ``mcp.session.other`` so the mandate can
|
||||
still authorise session-level JSON-RPC without enumerating every
|
||||
method on both sides.
|
||||
"""
|
||||
if method == "tools/call":
|
||||
if tool_name is None or tool_name not in TOOL_ACTION:
|
||||
raise ValueError(f"unknown tool: {tool_name!r}")
|
||||
return TOOL_ACTION[tool_name]
|
||||
if method == "initialize":
|
||||
return "mcp.session.initialize"
|
||||
if method == "tools/list":
|
||||
return "mcp.session.list_tools"
|
||||
return "mcp.session.other"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MintedMandate:
|
||||
compact: str
|
||||
mandate: ACTMandate
|
||||
|
||||
|
||||
def mint_mandate(
|
||||
*,
|
||||
user: Identity,
|
||||
agent: Identity,
|
||||
audience: str,
|
||||
purpose: str,
|
||||
ttl_seconds: int = 900,
|
||||
now: Optional[int] = None,
|
||||
) -> MintedMandate:
|
||||
"""User issues a Phase 1 ACT mandate to the agent.
|
||||
|
||||
Reference: ACT §3.1, §4.2.
|
||||
"""
|
||||
iat = int(now if now is not None else time.time())
|
||||
mandate = ACTMandate(
|
||||
alg="ES256",
|
||||
kid=user.kid,
|
||||
iss=user.name,
|
||||
sub=agent.name,
|
||||
aud=audience,
|
||||
iat=iat,
|
||||
exp=iat + ttl_seconds,
|
||||
jti=str(uuid.uuid4()),
|
||||
wid=agent.name,
|
||||
task=TaskClaim(purpose=purpose, created_by=user.name),
|
||||
cap=[Capability(action=a) for a in MCP_CAPS],
|
||||
)
|
||||
mandate.validate()
|
||||
signature = act_sign(user.private_key, mandate.signing_input())
|
||||
compact = encode_jws(mandate, signature)
|
||||
return MintedMandate(compact=compact, mandate=mandate)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MintedRecord:
|
||||
compact: str
|
||||
record: ACTRecord
|
||||
|
||||
|
||||
def mint_exec_record(
|
||||
*,
|
||||
agent: Identity,
|
||||
mandate: ACTMandate,
|
||||
exec_act: str,
|
||||
pred_jtis: list[str],
|
||||
inp_body: bytes,
|
||||
out_body: bytes,
|
||||
now: Optional[int] = None,
|
||||
status: str = "completed",
|
||||
) -> MintedRecord:
|
||||
"""Agent mints a Phase 2 ACT execution record after a tool call.
|
||||
|
||||
``pred_jtis`` should include the mandate jti and any prior exec record
|
||||
jtis for this run (DAG semantics, ACT §4.3 ``pred``).
|
||||
|
||||
Reference: ACT §3.2, §4.3.
|
||||
"""
|
||||
exec_ts = int(now if now is not None else time.time())
|
||||
|
||||
record = ACTRecord.from_mandate(
|
||||
mandate,
|
||||
kid=agent.kid,
|
||||
exec_act=exec_act,
|
||||
pred=pred_jtis,
|
||||
exec_ts=exec_ts,
|
||||
status=status,
|
||||
inp_hash=b64url_sha256(inp_body),
|
||||
out_hash=b64url_sha256(out_body),
|
||||
)
|
||||
# The record's jti MUST equal the mandate's jti per ACT §3.2 / §4 (the
|
||||
# Phase 2 token records the same task). ``from_mandate`` already copies
|
||||
# mandate.jti, so no override here.
|
||||
record.validate()
|
||||
signature = act_sign(agent.private_key, record.signing_input())
|
||||
compact = encode_jws(record, signature)
|
||||
return MintedRecord(compact=compact, record=record)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MintedECT:
|
||||
compact: str
|
||||
payload: ECTPayload
|
||||
|
||||
|
||||
def mint_ect(
|
||||
*,
|
||||
agent: Identity,
|
||||
audience: str,
|
||||
exec_act: str,
|
||||
pred_jtis: list[str],
|
||||
inp_body: bytes,
|
||||
now: Optional[int] = None,
|
||||
ttl_seconds: int = 300,
|
||||
) -> MintedECT:
|
||||
"""Agent mints an ECT binding the MCP request body to the execution.
|
||||
|
||||
The ECT's ``jti`` identifies this specific invocation; ``pred`` links
|
||||
back to the mandate (and earlier ECT invocations, if any); the request
|
||||
body is hashed into ``inp_hash``.
|
||||
|
||||
Reference: draft-nennemann-wimse-ect-01 §4.
|
||||
"""
|
||||
iat = int(now if now is not None else time.time())
|
||||
payload = ECTPayload(
|
||||
iss=agent.name,
|
||||
aud=[audience],
|
||||
iat=iat,
|
||||
exp=iat + ttl_seconds,
|
||||
jti=str(uuid.uuid4()),
|
||||
exec_act=exec_act,
|
||||
pred=pred_jtis,
|
||||
wid=agent.name,
|
||||
inp_hash=b64url_sha256(inp_body),
|
||||
)
|
||||
# ECT refimpl wants a real ES256 key and the same kid shape as ACT.
|
||||
compact = ect_create(
|
||||
payload,
|
||||
agent.private_key,
|
||||
CreateOptions(key_id=agent.kid),
|
||||
)
|
||||
# Reflect the defaulted iat/exp back onto our payload copy for logging.
|
||||
return MintedECT(compact=compact, payload=payload)
|
||||
188
demo/act-ect-mcp/src/poc/verify_cli.py
Normal file
188
demo/act-ect-mcp/src/poc/verify_cli.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Replay an agent run from ledger.jsonl, verify every token, print the DAG.
|
||||
|
||||
Model (spec-consistent)
|
||||
-----------------------
|
||||
Per run:
|
||||
|
||||
* 1 ACT Phase 1 mandate (user → agent)
|
||||
* N ECT tokens — one per outgoing MCP HTTP request. Tool-call ECTs form
|
||||
a DAG via their ``pred`` field; session ECTs (initialize/tools-list)
|
||||
only point at the mandate.
|
||||
* 1 ACT Phase 2 record summarising the run (jti = mandate.jti per
|
||||
ACT §3.2).
|
||||
|
||||
The verifier re-runs the ietf-act and ietf-ect refimpls on each compact
|
||||
form and prints both the coarse ACT summary and the fine-grained ECT DAG.
|
||||
|
||||
Run ``poc-verify [--ledger keys/ledger.jsonl] [--keys-dir keys]``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from act.crypto import ACTKeyResolver
|
||||
from act.errors import ACTError
|
||||
from act.verify import ACTVerifier
|
||||
|
||||
from ect.verify import verify as ect_verify, VerifyOptions
|
||||
|
||||
from .keys import build_ect_key_resolver, build_key_registry, load_identities
|
||||
|
||||
|
||||
SERVER_IDENTITY_NAME = "mcp-server"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
kind: str
|
||||
jti: str
|
||||
compact: str
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
def _read_ledger(path: Path) -> list[Row]:
|
||||
rows: list[Row] = []
|
||||
for line in path.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
obj = json.loads(line)
|
||||
rows.append(
|
||||
Row(
|
||||
kind=obj["kind"],
|
||||
jti=obj["jti"],
|
||||
compact=obj["compact"],
|
||||
metadata=obj.get("metadata", {}),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _fmt_jti(jti: str) -> str:
|
||||
return jti.split("-")[0]
|
||||
|
||||
|
||||
def run(ledger_path: Path, keys_dir: Path) -> int:
|
||||
identities = load_identities(keys_dir)
|
||||
registry = build_key_registry(identities)
|
||||
resolver = ACTKeyResolver(registry=registry)
|
||||
ect_resolver = build_ect_key_resolver(identities)
|
||||
trusted_issuers = {ident.name for ident in identities.values()}
|
||||
|
||||
verifier = ACTVerifier(
|
||||
resolver,
|
||||
verifier_id=SERVER_IDENTITY_NAME,
|
||||
trusted_issuers=trusted_issuers,
|
||||
)
|
||||
|
||||
rows = _read_ledger(ledger_path)
|
||||
mandates = [r for r in rows if r.kind == "mandate"]
|
||||
records = [r for r in rows if r.kind == "record"]
|
||||
ect_rows = [r for r in rows if r.kind == "ect"]
|
||||
if len(mandates) != 1:
|
||||
raise SystemExit(
|
||||
f"expected exactly one mandate, got {len(mandates)} in {ledger_path}"
|
||||
)
|
||||
if len(records) != 1:
|
||||
raise SystemExit(
|
||||
f"expected exactly one record, got {len(records)} in {ledger_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
mandate = verifier.verify_mandate(mandates[0].compact, check_sub=False)
|
||||
except ACTError as e:
|
||||
raise SystemExit(f"mandate verification failed: {e}")
|
||||
print(f"mandate verified jti={_fmt_jti(mandate.jti)}")
|
||||
|
||||
# ECT verification — includes refimpl DAG walk when we supply a store.
|
||||
# We don't supply one here because ECTStore would need cross-run scoping.
|
||||
# Each ECT still passes its own Section-7 verification individually.
|
||||
ect_parsed: list[Any] = []
|
||||
ect_sessions = 0
|
||||
ect_tool_calls = 0
|
||||
for row in ect_rows:
|
||||
parsed = ect_verify(
|
||||
row.compact,
|
||||
VerifyOptions(verifier_id=SERVER_IDENTITY_NAME, resolve_key=ect_resolver),
|
||||
)
|
||||
ect_parsed.append(parsed)
|
||||
if row.metadata.get("session_only"):
|
||||
ect_sessions += 1
|
||||
else:
|
||||
ect_tool_calls += 1
|
||||
print(
|
||||
f"ects verified n={len(ect_parsed)} "
|
||||
f"(tool-calls={ect_tool_calls}, session={ect_sessions})"
|
||||
)
|
||||
|
||||
# Final ACT record — verify without the DAG store (pred=[] for our model).
|
||||
try:
|
||||
record = verifier.verify_record(records[0].compact, store=None)
|
||||
except ACTError as e:
|
||||
raise SystemExit(f"record verification failed: {e}")
|
||||
if record.jti != mandate.jti:
|
||||
raise SystemExit(
|
||||
f"record.jti {record.jti!r} != mandate.jti {mandate.jti!r} "
|
||||
"— ACT §3.2 violation"
|
||||
)
|
||||
print(f"record verified jti={_fmt_jti(record.jti)} status={record.status}")
|
||||
|
||||
# Cross-check: ECT DAG well-formedness within this run.
|
||||
known_jtis = {mandate.jti} | {p.payload.jti for p in ect_parsed}
|
||||
dangling = 0
|
||||
for p in ect_parsed:
|
||||
for pred in p.payload.pred:
|
||||
if pred not in known_jtis:
|
||||
dangling += 1
|
||||
if dangling:
|
||||
raise SystemExit(f"ECT DAG has {dangling} dangling predecessor ref(s)")
|
||||
print("ect-dag wellformed every pred is the mandate or a prior ECT")
|
||||
|
||||
# ---- Render ------------------------------------------------------------
|
||||
print()
|
||||
print("Run")
|
||||
print("===")
|
||||
print(f" mandate {_fmt_jti(mandate.jti)} task={mandate.task.purpose!r}")
|
||||
print(f" iss={mandate.iss} sub={mandate.sub} aud={mandate.aud}")
|
||||
print(f" cap={[c.action for c in mandate.cap]}")
|
||||
print()
|
||||
print("Tool-call ECT DAG:")
|
||||
tool_only = [
|
||||
p
|
||||
for p, row in zip(ect_parsed, ect_rows)
|
||||
if not row.metadata.get("session_only")
|
||||
]
|
||||
if not tool_only:
|
||||
print(" (none — model called no tools)")
|
||||
for p in tool_only:
|
||||
preds = [_fmt_jti(x) for x in p.payload.pred]
|
||||
print(
|
||||
f" ect {_fmt_jti(p.payload.jti)} exec_act={p.payload.exec_act} "
|
||||
f"pred={preds}"
|
||||
)
|
||||
print()
|
||||
print("ACT Phase 2 record:")
|
||||
print(f" jti={_fmt_jti(record.jti)} exec_act={record.exec_act}")
|
||||
print(f" status={record.status} pred={list(record.pred)}")
|
||||
print(f" inp_hash={record.inp_hash}")
|
||||
print(f" out_hash={record.out_hash}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Verify ACT+ECT PoC ledger")
|
||||
parser.add_argument(
|
||||
"--ledger", default=os.environ.get("POC_LEDGER", "keys/ledger.jsonl")
|
||||
)
|
||||
parser.add_argument("--keys-dir", default=os.environ.get("POC_KEYS_DIR", "keys"))
|
||||
args = parser.parse_args()
|
||||
raise SystemExit(run(Path(args.ledger), Path(args.keys_dir)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user