diff --git a/demo/act-ect-mcp/.gitignore b/demo/act-ect-mcp/.gitignore new file mode 100644 index 0000000..e64f801 --- /dev/null +++ b/demo/act-ect-mcp/.gitignore @@ -0,0 +1,9 @@ +keys/*.pem +keys/*.json +!keys/.gitkeep +__pycache__/ +*.egg-info/ +.pytest_cache/ +.venv/ +build/ +dist/ diff --git a/demo/act-ect-mcp/README.md b/demo/act-ect-mcp/README.md new file mode 100644 index 0000000..33b8394 --- /dev/null +++ b/demo/act-ect-mcp/README.md @@ -0,0 +1,159 @@ +# ACT + ECT + MCP + LangGraph — end-to-end PoC + +A working demonstration of `draft-nennemann-act-01` (Agent Context Token) +and `draft-nennemann-wimse-ect-01` (Execution Context Token) in a realistic +agent stack: + +* a **LangGraph** ReAct agent driven by a local **Ollama** LLM; +* talking over **MCP** streamable-HTTP to a FastMCP server; +* every request carries an **ACT** mandate, a per-call **ECT**, and an + **RFC 9421** HTTP signature with the `wimse-aud` parameter from + `draft-ietf-wimse-http-signature-03`; +* the server rejects any request where ACT / ECT / HTTP-signature / + capability / body-hash binding fails; +* a verifier CLI replays the run's ledger, re-runs the two refimpls, and + prints the resulting DAG. + +## Why this exists + +The two drafts (ACT and ECT) claim to fit together — ACT giving the +lifecycle (mandate → execution record) and ECT giving the per-call +execution context on the wire. This PoC proves the claim end to end: +the same refimpls that ship in `workspace/packages/{act,ect}/` are the +only crypto/verification layer used here. There is no token forgery +shortcut. + +## Requirements + +* `uv` ([install](https://docs.astral.sh/uv/)) +* Local Ollama with a chat model pulled (`qwen3:8b` by default) +* Python 3.11+ (uv will fetch one if missing) + +## Run the demo + +```bash +./demo.sh +``` + +This script: + +1. Syncs deps (uv installs the sibling `ietf-act` and `ietf-ect` + packages in editable mode, plus `mcp`, `langgraph`, + `langchain-ollama`, `langchain-mcp-adapters`, `fastapi`, …). +2. Launches the MCP server on `127.0.0.1:8765`. +3. Runs the agent (`poc-agent`) against it with a canned research task. +4. Runs the verifier (`poc-verify`) over the ledger the agent emitted. +5. Shows the last five server-audit entries. + +Expected tail of output: + +``` +mandate verified jti=64f5ec87 +ects verified n=7 (tool-calls=2, session=5) +record verified jti=64f5ec87 status=completed +ect-dag wellformed every pred is the mandate or a prior ECT + +Run +=== + mandate 64f5ec87 task='Search for quantum entanglement, …' + iss=user sub=agent aud=mcp-server + cap=['mcp.session.initialize', 'mcp.session.list_tools', 'mcp.session.other', 'mcp.search', 'mcp.summarize'] + +Tool-call ECT DAG: + ect 73af4cd3 exec_act=mcp.search pred=['64f5ec87'] + ect 0e3ffa01 exec_act=mcp.summarize pred=['64f5ec87', '73af4cd3'] + +ACT Phase 2 record: + jti=64f5ec87 exec_act=mcp.summarize + status=completed pred=[] + inp_hash=… out_hash=… +``` + +The mandate jti and the record jti are identical — this is ACT §3.2: +the Phase 2 token records the same task it started as. The tool-call +ECT DAG captures the per-HTTP-request ordering. + +## Architecture + +``` +user ──(mints ACT mandate)──► agent + │ + │ create_react_agent(ChatOllama, tools) + ▼ + ┌──────────────────────────────┐ + │ LangGraph ReAct loop │ + │ LLM decides tools to call │ + │ │ + │ langchain-mcp-adapters │ + │ ↑ streamable_http session │ + └──────────┬───────────────────┘ + │ + httpx.AsyncClient with event hooks + │ + ┌───────────▼─────────────┐ + │ on_request: │ + │ - mint ECT(inp_hash) │ + │ - RFC 9421 sign │ + │ - attach Authorization │ + │ + Wimse-ECT + sig │ + └───────────┬──────────────┘ + │ + POST /mcp + │ + ┌───────────▼──────────────┐ + │ FastMCP streamable-http │ + │ + ActEctAuthMiddleware │ + │ verifies: │ + │ ACT mandate │ + │ ECT │ + │ HTTP-signature │ + │ inp_hash == body │ + │ exec_act in cap │ + │ ECT.iss == sub │ + │ then dispatches tool │ + └──────────────────────────┘ +``` + +## Files + +* `src/poc/keys.py` — ES256 keys for the three PoC identities + (`user`, `agent`, `mcp-server`). +* `src/poc/tokens.py` — thin wrappers around `ietf-act` and `ietf-ect` + that fix the PoC's shape. +* `src/poc/http_sig.py` — minimal RFC 9421 signer/verifier covering + `@method`, `@target-uri`, `content-digest`, `wimse-ect`, with the + `wimse-aud` metadata parameter from http-signature-03. +* `src/poc/server.py` — FastMCP server with ACT + ECT + signature + middleware. Writes `keys/server-audit.jsonl`. +* `src/poc/agent.py` — LangGraph + Ollama agent. Writes + `keys/ledger.jsonl` — one mandate, N ECTs, one final ACT record. +* `src/poc/verify_cli.py` — ledger verifier, prints the DAG. +* `tests/` — pytest suite (see below). + +## Non-goals + +* No real LLM API costs (Ollama is local). +* No distributed SCITT anchoring — server audit log is a plain JSONL. +* No Go-side client in this PoC; Python ↔ Python. Go refimpl lives in + `workspace/drafts/ietf-wimse-ect/refimpl/go-lang/`. +* The PoC uses a single mandate per run and a single Phase 2 record + (ACT §3.2 jti preservation). If you want a multi-record DAG of ACT + tasks, you'd need to exercise the delegation machinery + (`act.delegation.create_delegated_mandate`) — this PoC does not, to + keep the wire story tight. + +## Tests + +```bash +uv run pytest +``` + +Covers: + +* Minting + round-trip verification of each token type. +* HTTP-signature round-trip. +* Middleware rejection paths (missing headers, wrong audience, stolen + ECT on a mutated body). +* End-to-end: launches an in-process server via httpx's ASGI transport + and runs the agent's token-injection hooks over it (no Ollama + required — uses a fake LLM). diff --git a/demo/act-ect-mcp/demo.sh b/demo/act-ect-mcp/demo.sh new file mode 100755 index 0000000..e8fcf4c --- /dev/null +++ b/demo/act-ect-mcp/demo.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# End-to-end demo: start MCP server, run the LangGraph agent, verify the DAG. +# +# Requirements: +# - uv installed (https://docs.astral.sh/uv/) +# - Ollama running locally with `qwen3:8b` pulled +# (override via POC_MODEL and OLLAMA_HOST env vars) +# +# The script is idempotent — keys and ledgers are written under ./keys/. + +set -euo pipefail + +cd "$(dirname "$0")" + +POC_MODEL="${POC_MODEL:-qwen3:8b}" +POC_PORT="${POC_PORT:-8765}" +POC_PURPOSE="${POC_PURPOSE:-Search for quantum entanglement, then summarise the top result.}" + +mkdir -p keys +rm -f keys/ledger.jsonl keys/server-audit.jsonl + +echo "==> syncing dependencies" +uv sync --quiet + +echo "==> starting MCP server on 127.0.0.1:${POC_PORT}" +uv run python -m poc.server --port "${POC_PORT}" >/tmp/poc-server.log 2>&1 & +SERVER_PID=$! +trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT + +for _ in $(seq 1 25); do + if curl -sSf -o /dev/null -X POST "http://127.0.0.1:${POC_PORT}/mcp" \ + -H 'content-type: application/json' \ + --data '{"jsonrpc":"2.0","id":0,"method":"initialize"}' 2>/dev/null \ + || curl -sS "http://127.0.0.1:${POC_PORT}/mcp" -o /dev/null; then + break + fi + sleep 0.2 +done + +echo "==> running agent (model=${POC_MODEL})" +uv run poc-agent \ + --purpose "${POC_PURPOSE}" \ + --model "${POC_MODEL}" \ + --mcp-url "http://127.0.0.1:${POC_PORT}/mcp" + +echo +echo "==> verifying ledger" +uv run poc-verify + +echo +echo "==> server audit log (last 5 lines)" +tail -n 5 keys/server-audit.jsonl || true + +echo +echo "demo OK" diff --git a/demo/act-ect-mcp/keys/.gitkeep b/demo/act-ect-mcp/keys/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/demo/act-ect-mcp/keys/ledger.jsonl b/demo/act-ect-mcp/keys/ledger.jsonl new file mode 100644 index 0000000..417dbe5 --- /dev/null +++ b/demo/act-ect-mcp/keys/ledger.jsonl @@ -0,0 +1,13 @@ +{"kind":"mandate","jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","compact":"eyJhbGciOiJFUzI1NiIsInR5cCI6ImFjdCtqd3QiLCJraWQiOiJraWQ6dXNlcjp2MSJ9.eyJpc3MiOiJ1c2VyIiwic3ViIjoiYWdlbnQiLCJhdWQiOiJtY3Atc2VydmVyIiwiaWF0IjoxNzc1OTc2NzY5LCJleHAiOjE3NzU5Nzc2NjksImp0aSI6IjJhOTJjNWI3LWRkMGYtNDRmNS04NzY4LWQxYzNiZjhmMWNmNyIsInRhc2siOnsicHVycG9zZSI6IlNlYXJjaCBmb3IgcXVhbnR1bSBlbnRhbmdsZW1lbnQsIHRoZW4gc3VtbWFyaXNlIHRoZSB0b3AgcmVzdWx0LiIsImNyZWF0ZWRfYnkiOiJ1c2VyIn0sImNhcCI6W3siYWN0aW9uIjoibWNwLnNlc3Npb24uaW5pdGlhbGl6ZSJ9LHsiYWN0aW9uIjoibWNwLnNlc3Npb24ubGlzdF90b29scyJ9LHsiYWN0aW9uIjoibWNwLnNlc3Npb24ub3RoZXIifSx7ImFjdGlvbiI6Im1jcC5zZWFyY2gifSx7ImFjdGlvbiI6Im1jcC5zdW1tYXJpemUifV0sIndpZCI6ImFnZW50In0.X6uhGB4FYY4PsS7np1GFL-4z-lhMdClKhq5G8T9Nic4DUxUeFKwW6aaqYxf20TjbhLfs23Zwq_Nv4jRy8KjTZA","metadata":{"iss":"user","sub":"agent","aud":"mcp-server","task":{"purpose":"Search for quantum entanglement, then summarise the top result.","created_by":"user"},"cap":[{"action":"mcp.session.initialize"},{"action":"mcp.session.list_tools"},{"action":"mcp.session.other"},{"action":"mcp.search"},{"action":"mcp.summarize"}]}} +{"kind":"ect","jti":"c5655719-7187-47db-8080-d0ed95ad1740","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NjksImV4cCI6MTc3NTk3NzA2OSwianRpIjoiYzU2NTU3MTktNzE4Ny00N2RiLTgwODAtZDBlZDk1YWQxNzQwIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5pbml0aWFsaXplIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6ImxmUDBFbWUwcDBjeVNUa01fbzFfc1ZGNzhRQnBOdF9LVjRXRGtjdE1oZkUifQ.9Gy14pINnRn5WYvz2XW4LdDvV_G8ZqJb5TVd-hpO_q0aTbL0HrgNL5bK_zKf_FoFYU9DTcWkd_ukJPhd_eF11A","metadata":{"method":"initialize","exec_act":"mcp.session.initialize","session_only":true}} +{"kind":"ect","jti":"9c31c1b8-0b50-44e9-a58f-a751a659a16a","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NjksImV4cCI6MTc3NTk3NzA2OSwianRpIjoiOWMzMWMxYjgtMGI1MC00NGU5LWE1OGYtYTc1MWE2NTlhMTZhIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5vdGhlciIsInByZWQiOlsiMmE5MmM1YjctZGQwZi00NGY1LTg3NjgtZDFjM2JmOGYxY2Y3Il0sIndpZCI6ImFnZW50IiwiaW5wX2hhc2giOiJHWkZFREhRemRjd19jUjdEZUl4ekxQXzNHWVNUT292M053SF9WalFuaGcwIn0.dSNnWYdk35hiz_7rVa8BOrz2fRtLDonGBlGCIdprdVUF7qD1pLnZJY6koG04gE2Ayn5yXnN_jAr4e_KRYhfyNQ","metadata":{"method":"notifications/initialized","exec_act":"mcp.session.other","session_only":true}} +{"kind":"ect","jti":"f6d45c98-fede-4d85-9fd6-39993a68cb42","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NjksImV4cCI6MTc3NTk3NzA2OSwianRpIjoiZjZkNDVjOTgtZmVkZS00ZDg1LTlmZDYtMzk5OTNhNjhjYjQyIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5saXN0X3Rvb2xzIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6ImRmREhZbm8xeC1scUNvaElKaUtnMGV4LWdjRHJWQlNvaTZlcXVVVU05enMifQ.5xqO6a4OEliM2QXB1ZnVIPSdJVZtffwcdILnbnKsnMFI5lRTScE2DGBjMC_fo67dxgmfbR950wE-BpkHga03Ig","metadata":{"method":"tools/list","exec_act":"mcp.session.list_tools","session_only":true}} +{"kind":"ect","jti":"eeca8478-4df0-46a5-8c80-6f31913226e3","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzIsImV4cCI6MTc3NTk3NzA3MiwianRpIjoiZWVjYTg0NzgtNGRmMC00NmE1LThjODAtNmYzMTkxMzIyNmUzIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5pbml0aWFsaXplIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6ImxmUDBFbWUwcDBjeVNUa01fbzFfc1ZGNzhRQnBOdF9LVjRXRGtjdE1oZkUifQ.vnxBJGIq25E6kQpwkmkG3AyiXYXTCumuyVq_BNJ4fA61xaR4kO2_zNokxo3uJ9hBP6JDCEXpTGlvoLHgtgcTEA","metadata":{"method":"initialize","exec_act":"mcp.session.initialize","session_only":true}} +{"kind":"ect","jti":"c359aebf-d687-44f6-8c94-12493b387969","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzIsImV4cCI6MTc3NTk3NzA3MiwianRpIjoiYzM1OWFlYmYtZDY4Ny00NGY2LThjOTQtMTI0OTNiMzg3OTY5IiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5vdGhlciIsInByZWQiOlsiMmE5MmM1YjctZGQwZi00NGY1LTg3NjgtZDFjM2JmOGYxY2Y3Il0sIndpZCI6ImFnZW50IiwiaW5wX2hhc2giOiJHWkZFREhRemRjd19jUjdEZUl4ekxQXzNHWVNUT292M053SF9WalFuaGcwIn0.3baO9jqD74qXgb70Ffh0FjmhYX4Kc974La6uu__PNv15KydtaOoo530NlKhCEJ5Y-B10eoeXd51P9emtXGNlxg","metadata":{"method":"notifications/initialized","exec_act":"mcp.session.other","session_only":true}} +{"kind":"ect","jti":"7ac2034b-ac27-42b8-bb9c-1d790ac9a4f0","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzIsImV4cCI6MTc3NTk3NzA3MiwianRpIjoiN2FjMjAzNGItYWMyNy00MmI4LWJiOWMtMWQ3OTBhYzlhNGYwIiwiZXhlY19hY3QiOiJtY3Auc2VhcmNoIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6IlFrVUZBUmJVTDJMQzhMb2VDcTItVWpldFlfWG5oWWs1UjRYRUZOZG1SQlkifQ.u0bwRvmuUC1IDv7fmBy0pFb1ozeDnJHbnUr3X4jd3ClbeFX2aSZOVodB7HO97MBJdiavFebo01ZAQXJsbhb6MQ","metadata":{"method":"tools/call","tool_name":"search","exec_act":"mcp.search","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"]}} +{"kind":"ect","jti":"295f6c79-df73-4851-abeb-07e5c9282268","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzIsImV4cCI6MTc3NTk3NzA3MiwianRpIjoiMjk1ZjZjNzktZGY3My00ODUxLWFiZWItMDdlNWM5MjgyMjY4IiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5saXN0X3Rvb2xzIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6IkZBb1FWQkpXZTdMWlJUMFJfS0RNMDBWUk9hdmpCcjVveTJGblVUaDh4VzgifQ.oy7Ayuz-xlwx6E-VWi5-71vhUwtPzjl-SejZRiO0vxO4rxO8K5Qua2IjN4D1w77VZcSq-3EhWOuu-BZ0eSKYvg","metadata":{"method":"tools/list","exec_act":"mcp.session.list_tools","session_only":true}} +{"kind":"ect","jti":"d242256f-f17c-4b3d-995d-60f13beacc25","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzcsImV4cCI6MTc3NTk3NzA3NywianRpIjoiZDI0MjI1NmYtZjE3Yy00YjNkLTk5NWQtNjBmMTNiZWFjYzI1IiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5pbml0aWFsaXplIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6ImxmUDBFbWUwcDBjeVNUa01fbzFfc1ZGNzhRQnBOdF9LVjRXRGtjdE1oZkUifQ.otfuVqTG-nmSpv_WOA9G-1BoJqs_Hpv-Y6BmwRFBgxMvhM0e4KhfUKqh0BPFnweQNqoJvoLW_jP2uh6wepBkxg","metadata":{"method":"initialize","exec_act":"mcp.session.initialize","session_only":true}} +{"kind":"ect","jti":"2eb7eef6-6195-44a1-a88f-78be075cdf17","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzcsImV4cCI6MTc3NTk3NzA3NywianRpIjoiMmViN2VlZjYtNjE5NS00NGExLWE4OGYtNzhiZTA3NWNkZjE3IiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5vdGhlciIsInByZWQiOlsiMmE5MmM1YjctZGQwZi00NGY1LTg3NjgtZDFjM2JmOGYxY2Y3Il0sIndpZCI6ImFnZW50IiwiaW5wX2hhc2giOiJHWkZFREhRemRjd19jUjdEZUl4ekxQXzNHWVNUT292M053SF9WalFuaGcwIn0.qVPXigUqNu_bUMhYJrJQW6teu626-AZANpX7m-4o42ZsKAoNYE9D-xnOKdRnkcMybUVVKJlICPMWO9EU9ds_Hw","metadata":{"method":"notifications/initialized","exec_act":"mcp.session.other","session_only":true}} +{"kind":"ect","jti":"27c2682f-3b0a-4658-8710-113ae45864c1","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzcsImV4cCI6MTc3NTk3NzA3NywianRpIjoiMjdjMjY4MmYtM2IwYS00NjU4LTg3MTAtMTEzYWU0NTg2NGMxIiwiZXhlY19hY3QiOiJtY3Auc3VtbWFyaXplIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciLCI3YWMyMDM0Yi1hYzI3LTQyYjgtYmI5Yy0xZDc5MGFjOWE0ZjAiXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6Ilg5b2VtUFlUTUx3anZCclBYSTZfQ0s0WWZ5T01KemZYTnRpTk5FY3FHTU0ifQ.kjapcaU417lSbAxjL9qzhKJdq3WxK5AwytOfAItWZxhTOnQ11HgGQ6nGmwdKxRv47mYxVo8xe5XsMSuwF9mLqw","metadata":{"method":"tools/call","tool_name":"summarize","exec_act":"mcp.summarize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","7ac2034b-ac27-42b8-bb9c-1d790ac9a4f0"]}} +{"kind":"ect","jti":"e220a1d8-ec56-43f8-9952-a1f4387f607b","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzcsImV4cCI6MTc3NTk3NzA3NywianRpIjoiZTIyMGExZDgtZWM1Ni00M2Y4LTk5NTItYTFmNDM4N2Y2MDdiIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5saXN0X3Rvb2xzIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6IkZBb1FWQkpXZTdMWlJUMFJfS0RNMDBWUk9hdmpCcjVveTJGblVUaDh4VzgifQ.10ySm9JKxbH1ytgl0R-9NfApjED8qJw5awRUWwpzRVuA8CU5nsiyni0VYuyaOstfsrTUKtb71NmRLjcPBaSF1Q","metadata":{"method":"tools/list","exec_act":"mcp.session.list_tools","session_only":true}} +{"kind":"record","jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","compact":"eyJhbGciOiJFUzI1NiIsInR5cCI6ImFjdCtqd3QiLCJraWQiOiJraWQ6YWdlbnQ6djEifQ.eyJpc3MiOiJ1c2VyIiwic3ViIjoiYWdlbnQiLCJhdWQiOiJtY3Atc2VydmVyIiwiaWF0IjoxNzc1OTc2NzY5LCJleHAiOjE3NzU5Nzc2NjksImp0aSI6IjJhOTJjNWI3LWRkMGYtNDRmNS04NzY4LWQxYzNiZjhmMWNmNyIsInRhc2siOnsicHVycG9zZSI6IlNlYXJjaCBmb3IgcXVhbnR1bSBlbnRhbmdsZW1lbnQsIHRoZW4gc3VtbWFyaXNlIHRoZSB0b3AgcmVzdWx0LiIsImNyZWF0ZWRfYnkiOiJ1c2VyIn0sImNhcCI6W3siYWN0aW9uIjoibWNwLnNlc3Npb24uaW5pdGlhbGl6ZSJ9LHsiYWN0aW9uIjoibWNwLnNlc3Npb24ubGlzdF90b29scyJ9LHsiYWN0aW9uIjoibWNwLnNlc3Npb24ub3RoZXIifSx7ImFjdGlvbiI6Im1jcC5zZWFyY2gifSx7ImFjdGlvbiI6Im1jcC5zdW1tYXJpemUifV0sImV4ZWNfYWN0IjoibWNwLnN1bW1hcml6ZSIsInByZWQiOltdLCJleGVjX3RzIjoxNzc1OTc2NzgxLCJzdGF0dXMiOiJjb21wbGV0ZWQiLCJ3aWQiOiJhZ2VudCIsImlucF9oYXNoIjoiLWJMNF9Wb2JDWkNxZnFIc2RqN1hCR2tyeFpYaGE5VVp2YURCNkVTNGFYVSIsIm91dF9oYXNoIjoicFhXdjdKSjVtSmVSRDk3czlrMk41bkF4cVo0ajJPc3ZPNFM1WmJHNzVFdyJ9.PXoZHHCBizZiTLB_NL5o1j2O-wCEt6Px7syWP53pAaAUohdzNsbUGVgKR4LcXriP98yG8JnvbfPSa7tG1rA6qQ","metadata":{"exec_act":"mcp.summarize","status":"completed","pred":[],"inp_hash":"-bL4_VobCZCqfqHsdj7XBGkrxZXha9UZvaDB6ES4aXU","out_hash":"pXWv7JJ5mJeRD97s9k2N5nAxqZ4j2OsvO4S5ZbG75Ew","n_tool_ects":2}} diff --git a/demo/act-ect-mcp/keys/server-audit.jsonl b/demo/act-ect-mcp/keys/server-audit.jsonl new file mode 100644 index 0000000..ccf0e61 --- /dev/null +++ b/demo/act-ect-mcp/keys/server-audit.jsonl @@ -0,0 +1,11 @@ +{"ts":1775976769,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"c5655719-7187-47db-8080-d0ed95ad1740","exec_act":"mcp.session.initialize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"lfP0Eme0p0cySTkM_o1_sVF78QBpNt_KV4WDkctMhfE","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976769,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"9c31c1b8-0b50-44e9-a58f-a751a659a16a","exec_act":"mcp.session.other","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"GZFEDHQzdcw_cR7DeIxzLP_3GYSTOov3NwH_VjQnhg0","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976769,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"f6d45c98-fede-4d85-9fd6-39993a68cb42","exec_act":"mcp.session.list_tools","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"dfDHYno1x-lqCohIJiKg0ex-gcDrVBSoi6equUUM9zs","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976772,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"eeca8478-4df0-46a5-8c80-6f31913226e3","exec_act":"mcp.session.initialize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"lfP0Eme0p0cySTkM_o1_sVF78QBpNt_KV4WDkctMhfE","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976772,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"c359aebf-d687-44f6-8c94-12493b387969","exec_act":"mcp.session.other","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"GZFEDHQzdcw_cR7DeIxzLP_3GYSTOov3NwH_VjQnhg0","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976772,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"7ac2034b-ac27-42b8-bb9c-1d790ac9a4f0","exec_act":"mcp.search","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"QkUFARbUL2LC8LoeCq2-UjetY_XnhYk5R4XEFNdmRBY","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976772,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"295f6c79-df73-4851-abeb-07e5c9282268","exec_act":"mcp.session.list_tools","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"FAoQVBJWe7LZRT0R_KDM00VROavjBr5oy2FnUTh8xW8","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976777,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"d242256f-f17c-4b3d-995d-60f13beacc25","exec_act":"mcp.session.initialize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"lfP0Eme0p0cySTkM_o1_sVF78QBpNt_KV4WDkctMhfE","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976777,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"2eb7eef6-6195-44a1-a88f-78be075cdf17","exec_act":"mcp.session.other","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"GZFEDHQzdcw_cR7DeIxzLP_3GYSTOov3NwH_VjQnhg0","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976777,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"27c2682f-3b0a-4658-8710-113ae45864c1","exec_act":"mcp.summarize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","7ac2034b-ac27-42b8-bb9c-1d790ac9a4f0"],"inp_hash":"X9oemPYTMLwjvBrPXI6_CK4YfyOMJzfXNtiNNEcqGMM","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} +{"ts":1775976777,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"e220a1d8-ec56-43f8-9952-a1f4387f607b","exec_act":"mcp.session.list_tools","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"FAoQVBJWe7LZRT0R_KDM00VROavjBr5oy2FnUTh8xW8","wimse_aud":"mcp-server","keyid":"kid:agent:v1"} diff --git a/demo/act-ect-mcp/pyproject.toml b/demo/act-ect-mcp/pyproject.toml new file mode 100644 index 0000000..b3af31f --- /dev/null +++ b/demo/act-ect-mcp/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "act-ect-poc" +version = "0.1.0" +description = "End-to-end PoC: LangGraph agent calling MCP tools with ACT mandate + ECT execution context" +requires-python = ">=3.11" +dependencies = [ + # Our refimpls (installed from sibling packages) + "ietf-act", + "ietf-ect", + # MCP server + client + "mcp>=1.6.0", + # LangGraph + LLM plumbing + "langgraph>=0.3.0", + "langchain>=0.3.0", + "langchain-core>=0.3.0", + "langchain-ollama>=0.2.0", + "langchain-mcp-adapters>=0.1.0", + # HTTP server + client + "fastapi>=0.110", + "uvicorn[standard]>=0.29", + "httpx>=0.27", + # Crypto / JWT + "cryptography>=42.0", + "PyJWT>=2.8.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-asyncio>=0.23"] + +[project.scripts] +poc-server = "poc.server:main" +poc-agent = "poc.agent:main" +poc-verify = "poc.verify_cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.uv.sources] +ietf-act = { path = "../../workspace/packages/act", editable = true } +ietf-ect = { path = "../../workspace/packages/ect", editable = true } + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/demo/act-ect-mcp/src/poc/__init__.py b/demo/act-ect-mcp/src/poc/__init__.py new file mode 100644 index 0000000..a6e7d17 --- /dev/null +++ b/demo/act-ect-mcp/src/poc/__init__.py @@ -0,0 +1,3 @@ +"""ACT + ECT + MCP + LangGraph end-to-end PoC.""" + +__version__ = "0.1.0" diff --git a/demo/act-ect-mcp/src/poc/agent.py b/demo/act-ect-mcp/src/poc/agent.py new file mode 100644 index 0000000..11151ac --- /dev/null +++ b/demo/act-ect-mcp/src/poc/agent.py @@ -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 ``, ``Wimse-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() diff --git a/demo/act-ect-mcp/src/poc/http_sig.py b/demo/act-ect-mcp/src/poc/http_sig.py new file mode 100644 index 0000000..405911c --- /dev/null +++ b/demo/act-ect-mcp/src/poc/http_sig.py @@ -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=:: +""" + +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 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 diff --git a/demo/act-ect-mcp/src/poc/keys.py b/demo/act-ect-mcp/src/poc/keys.py new file mode 100644 index 0000000..b1099a1 --- /dev/null +++ b/demo/act-ect-mcp/src/poc/keys.py @@ -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 diff --git a/demo/act-ect-mcp/src/poc/server.py b/demo/act-ect-mcp/src/poc/server.py new file mode 100644 index 0000000..f3e513c --- /dev/null +++ b/demo/act-ect-mcp/src/poc/server.py @@ -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 `` and verifies the + Phase 1 mandate (ACT §8.1); + 2. parses ``Wimse-ECT: `` 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 + 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 = 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() diff --git a/demo/act-ect-mcp/src/poc/tokens.py b/demo/act-ect-mcp/src/poc/tokens.py new file mode 100644 index 0000000..06e5809 --- /dev/null +++ b/demo/act-ect-mcp/src/poc/tokens.py @@ -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) diff --git a/demo/act-ect-mcp/src/poc/verify_cli.py b/demo/act-ect-mcp/src/poc/verify_cli.py new file mode 100644 index 0000000..96a35f7 --- /dev/null +++ b/demo/act-ect-mcp/src/poc/verify_cli.py @@ -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() diff --git a/demo/act-ect-mcp/tests/__init__.py b/demo/act-ect-mcp/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/act-ect-mcp/tests/conftest.py b/demo/act-ect-mcp/tests/conftest.py new file mode 100644 index 0000000..ba05a61 --- /dev/null +++ b/demo/act-ect-mcp/tests/conftest.py @@ -0,0 +1,17 @@ +import shutil +from pathlib import Path + +import pytest + + +@pytest.fixture +def tmp_keys_dir(tmp_path) -> Path: + d = tmp_path / "keys" + d.mkdir() + return d + + +@pytest.fixture +def identities(tmp_keys_dir): + from poc.keys import load_identities + return load_identities(tmp_keys_dir) diff --git a/demo/act-ect-mcp/tests/test_http_sig.py b/demo/act-ect-mcp/tests/test_http_sig.py new file mode 100644 index 0000000..270d282 --- /dev/null +++ b/demo/act-ect-mcp/tests/test_http_sig.py @@ -0,0 +1,84 @@ +"""RFC-9421-shaped HTTP signature round-trip and tamper-detection.""" + +from __future__ import annotations + +import pytest + +from act.errors import ACTSignatureError + +from poc.http_sig import sign_request, verify_request + + +def _sign_verify_ok(identities, body: bytes): + agent = identities["agent"] + target = "http://127.0.0.1:8765/mcp" + signed = sign_request( + method="POST", + target_uri=target, + body=body, + wimse_ect="ect.placeholder.compact", + wimse_aud=identities["mcp-server"].name, + keyid=agent.kid, + private_key=agent.private_key, + ) + parsed = verify_request( + method="POST", + target_uri=target, + body=body, + wimse_ect_header="ect.placeholder.compact", + content_digest_header=signed.content_digest, + signature_input_header=signed.signature_input, + signature_header=signed.signature, + expected_audience=identities["mcp-server"].name, + public_key=agent.public_key, + ) + return signed, parsed + + +def test_signature_round_trips(identities): + signed, parsed = _sign_verify_ok(identities, body=b'{"method":"tools/call"}') + assert parsed.keyid == identities["agent"].kid + assert parsed.wimse_aud == "mcp-server" + assert parsed.alg == "ecdsa-p256-sha256" + + +def test_signature_fails_on_tampered_body(identities): + agent = identities["agent"] + signed, _ = _sign_verify_ok(identities, body=b"original") + with pytest.raises(ACTSignatureError): + verify_request( + method="POST", + target_uri="http://127.0.0.1:8765/mcp", + body=b"tampered", # different body → different digest → no match + wimse_ect_header="ect.placeholder.compact", + content_digest_header=signed.content_digest, + signature_input_header=signed.signature_input, + signature_header=signed.signature, + expected_audience="mcp-server", + public_key=agent.public_key, + ) + + +def test_signature_fails_on_wrong_audience(identities): + agent = identities["agent"] + signed = sign_request( + method="POST", + target_uri="http://example/mcp", + body=b"{}", + wimse_ect="ect.placeholder", + wimse_aud="the-wrong-workload", # signed for the wrong audience + keyid=agent.kid, + private_key=agent.private_key, + ) + with pytest.raises(ACTSignatureError): + verify_request( + method="POST", + target_uri="http://example/mcp", + body=b"{}", + wimse_ect_header="ect.placeholder", + content_digest_header=signed.content_digest, + signature_input_header=signed.signature_input, + signature_header=signed.signature, + expected_audience="mcp-server", + public_key=agent.public_key, + ) diff --git a/demo/act-ect-mcp/tests/test_server.py b/demo/act-ect-mcp/tests/test_server.py new file mode 100644 index 0000000..8c94566 --- /dev/null +++ b/demo/act-ect-mcp/tests/test_server.py @@ -0,0 +1,191 @@ +"""In-process tests that exercise the server's auth middleware via ASGI. + +Uses ``httpx.AsyncClient`` with ``ASGITransport`` so no uvicorn / network is +required. Validates that a request forged with the real token-minting +pipeline reaches the FastMCP layer, and that tampering with any of the +pieces is rejected with 4xx. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from poc.http_sig import sign_request +from poc.server import build_app +from poc.tokens import mint_ect, mint_mandate + + +pytestmark = pytest.mark.asyncio + + +def _headers_for( + identities, + *, + body: bytes, + audience: str, + exec_act: str, + tamper_body: bool = False, + tamper_aud: bool = False, +) -> tuple[dict[str, str], bytes]: + """Build a full set of ACT+ECT+signature headers for one request.""" + agent = identities["agent"] + user = identities["user"] + mandate = mint_mandate( + user=user, agent=agent, audience=audience, purpose="test" + ) + ect = mint_ect( + agent=agent, + audience=audience, + exec_act=exec_act, + pred_jtis=[mandate.mandate.jti], + inp_body=body, + ) + sign_body = b"tampered" if tamper_body else body + sign_aud = "wrong-audience" if tamper_aud else audience + signed = sign_request( + method="POST", + target_uri="http://testserver/mcp", + body=sign_body, + wimse_ect=ect.compact, + wimse_aud=sign_aud, + keyid=agent.kid, + private_key=agent.private_key, + ) + headers = { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "authorization": f"Bearer {mandate.compact}", + "wimse-ect": ect.compact, + "content-digest": signed.content_digest, + "signature-input": signed.signature_input, + "signature": signed.signature, + } + return headers, body + + +from contextlib import asynccontextmanager + + +@asynccontextmanager +async def _client_for(identities): + """Return an httpx client wired to the ASGI app with lifespan started. + + FastMCP's streamable-HTTP transport allocates a task group during + ``lifespan.startup``; ``ASGITransport`` does not run lifespan by + default, so we manage it explicitly here. + """ + app = build_app(identities) + async with app.router.lifespan_context(app): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + yield client + + +async def test_no_auth_headers_returns_401(identities): + async with _client_for(identities) as c: + r = await c.post("/mcp", content=b'{"jsonrpc":"2.0","method":"initialize"}') + assert r.status_code == 401 + assert "Authorization" in r.text + + +async def test_valid_initialize_request_is_accepted(identities, tmp_path, monkeypatch): + monkeypatch.setenv("POC_AUDIT_LOG", str(tmp_path / "audit.jsonl")) + body = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "poc-test", "version": "0"}, + }, + }).encode("utf-8") + headers, _ = _headers_for( + identities, body=body, audience="mcp-server", + exec_act="mcp.session.initialize", + ) + async with _client_for(identities) as c: + r = await c.post("/mcp", content=body, headers=headers) + # FastMCP may respond 200 with a session id, or 202, or stream SSE. + assert r.status_code < 400, f"unexpected status {r.status_code}: {r.text}" + + +async def test_tampered_body_is_rejected(identities): + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode() + headers, _ = _headers_for( + identities, body=body, audience="mcp-server", + exec_act="mcp.session.initialize", tamper_body=True, + ) + async with _client_for(identities) as c: + r = await c.post("/mcp", content=body, headers=headers) + assert r.status_code == 401 + assert "content-digest" in r.text.lower() or "http-signature" in r.text.lower() + + +async def test_wrong_wimse_aud_is_rejected(identities): + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode() + headers, _ = _headers_for( + identities, body=body, audience="mcp-server", + exec_act="mcp.session.initialize", tamper_aud=True, + ) + async with _client_for(identities) as c: + r = await c.post("/mcp", content=body, headers=headers) + assert r.status_code == 401 + + +async def test_unauthorised_tool_is_rejected(identities): + """A tools/call whose exec_act is not in mandate.cap → 403.""" + body = json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "search", "arguments": {"query": "x"}}, + }).encode() + # Craft a mandate where we strip search out of cap by going through + # the token API: we fabricate headers with the right exec_act but a + # mandate whose cap doesn't contain it. + from poc.keys import Identity # noqa + from poc.tokens import MCP_CAPS, mint_mandate + agent = identities["agent"] + user = identities["user"] + + # Replace MCP_CAPS monkey-patch-free: build mandate directly. + from act.token import ACTMandate, Capability, TaskClaim + from act.crypto import sign as act_sign + from act.token import encode_jws + import time, uuid + iat = int(time.time()) + mandate = ACTMandate( + alg="ES256", kid=user.kid, iss="user", sub="agent", + aud="mcp-server", iat=iat, exp=iat + 600, jti=str(uuid.uuid4()), + wid="agent", + task=TaskClaim(purpose="p", created_by="user"), + cap=[Capability(action="mcp.summarize")], # search MISSING + ) + mandate.validate() + mandate_compact = encode_jws(mandate, act_sign(user.private_key, mandate.signing_input())) + + ect = mint_ect( + agent=agent, audience="mcp-server", + exec_act="mcp.search", pred_jtis=[mandate.jti], inp_body=body, + ) + signed = sign_request( + method="POST", target_uri="http://testserver/mcp", + body=body, wimse_ect=ect.compact, wimse_aud="mcp-server", + keyid=agent.kid, private_key=agent.private_key, + ) + headers = { + "content-type": "application/json", + "authorization": f"Bearer {mandate_compact}", + "wimse-ect": ect.compact, + "content-digest": signed.content_digest, + "signature-input": signed.signature_input, + "signature": signed.signature, + } + async with _client_for(identities) as c: + r = await c.post("/mcp", content=body, headers=headers) + assert r.status_code == 403 + assert "exec_act" in r.text or "cap" in r.text diff --git a/demo/act-ect-mcp/tests/test_tokens.py b/demo/act-ect-mcp/tests/test_tokens.py new file mode 100644 index 0000000..f07953b --- /dev/null +++ b/demo/act-ect-mcp/tests/test_tokens.py @@ -0,0 +1,136 @@ +"""Token minting + round-trip verification for all three PoC token types.""" + +from __future__ import annotations + +import pytest + +from poc.keys import build_ect_key_resolver, build_key_registry +from poc.tokens import mint_ect, mint_exec_record, mint_mandate + +from act.crypto import ACTKeyResolver +from act.errors import ACTError +from act.verify import ACTVerifier + +from ect.verify import verify as ect_verify, VerifyOptions + + +SERVER = "mcp-server" + + +def _act_verifier(identities) -> ACTVerifier: + reg = build_key_registry(identities) + return ACTVerifier( + ACTKeyResolver(registry=reg), + verifier_id=SERVER, + trusted_issuers={i.name for i in identities.values()}, + ) + + +def test_mandate_round_trips(identities): + m = mint_mandate( + user=identities["user"], + agent=identities["agent"], + audience=SERVER, + purpose="research task", + ) + v = _act_verifier(identities).verify_mandate(m.compact, check_sub=False) + assert v.jti == m.mandate.jti + assert v.iss == "user" + assert v.sub == "agent" + assert {c.action for c in v.cap} >= {"mcp.search", "mcp.summarize"} + + +def test_record_preserves_mandate_jti(identities): + """ACT §3.2: Phase 2 record carries the mandate's jti.""" + m = mint_mandate( + user=identities["user"], + agent=identities["agent"], + audience=SERVER, + purpose="research task", + ) + rec = mint_exec_record( + agent=identities["agent"], + mandate=m.mandate, + exec_act="mcp.search", + pred_jtis=[], + inp_body=b"input", + out_body=b"output", + ) + assert rec.record.jti == m.mandate.jti + + vr = _act_verifier(identities).verify_record(rec.compact) + assert vr.jti == m.mandate.jti + assert vr.exec_act == "mcp.search" + assert vr.status == "completed" + + +def test_record_rejects_unauthorised_exec_act(identities): + """Verifier must raise ACTCapabilityError when exec_act ∉ cap.""" + from act.errors import ACTCapabilityError + from act.token import Capability + + m = mint_mandate( + user=identities["user"], + agent=identities["agent"], + audience=SERVER, + purpose="p", + ) + # Narrow the mandate to only mcp.search so mcp.summarize is unauthorised. + m.mandate.cap = [Capability(action="mcp.search")] + + # Build the record locally so we can bypass the local validate() guard + # and produce a compact that only the verifier can spot as malformed. + rec = mint_exec_record( + agent=identities["agent"], + mandate=m.mandate, + exec_act="mcp.search", + pred_jtis=[], + inp_body=b"i", + out_body=b"o", + ) + # Swap exec_act *after* signing to simulate a forged record. The + # verifier should reject it on capability-consistency grounds (ACT §7.1). + import act.crypto as _crypto + from act.token import encode_jws + rec.record.exec_act = "mcp.summarize" + rec.record.cap = [Capability(action="mcp.search")] + tampered = encode_jws( + rec.record, + _crypto.sign(identities["agent"].private_key, rec.record.signing_input()), + ) + with pytest.raises(ACTCapabilityError): + _act_verifier(identities).verify_record(tampered) + + +def test_ect_round_trips(identities): + et = mint_ect( + agent=identities["agent"], + audience=SERVER, + exec_act="mcp.search", + pred_jtis=["some-prior-jti"], + inp_body=b'{"query":"x"}', + ) + parsed = ect_verify( + et.compact, + VerifyOptions( + verifier_id=SERVER, + resolve_key=build_ect_key_resolver(identities), + ), + ) + assert parsed.payload.iss == "agent" + assert parsed.payload.exec_act == "mcp.search" + assert parsed.payload.pred == ["some-prior-jti"] + assert parsed.payload.inp_hash # present + + +def test_wrong_audience_rejected_by_act_verifier(identities): + m = mint_mandate( + user=identities["user"], + agent=identities["agent"], + audience="some-other-workload", + purpose="p", + ) + # mcp-server is not the mandate's aud → verifier MUST refuse. + verifier = _act_verifier(identities) + with pytest.raises(ACTError): + verifier.verify_mandate(m.compact, check_sub=False)