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:
0
demo/act-ect-mcp/tests/__init__.py
Normal file
0
demo/act-ect-mcp/tests/__init__.py
Normal file
17
demo/act-ect-mcp/tests/conftest.py
Normal file
17
demo/act-ect-mcp/tests/conftest.py
Normal file
@@ -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)
|
||||
84
demo/act-ect-mcp/tests/test_http_sig.py
Normal file
84
demo/act-ect-mcp/tests/test_http_sig.py
Normal file
@@ -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,
|
||||
)
|
||||
191
demo/act-ect-mcp/tests/test_server.py
Normal file
191
demo/act-ect-mcp/tests/test_server.py
Normal file
@@ -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
|
||||
136
demo/act-ect-mcp/tests/test_tokens.py
Normal file
136
demo/act-ect-mcp/tests/test_tokens.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user