From 81b431b7f20703711cfb9f8d1bee90c8d006541d Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 23 Jul 2026 13:52:34 -0700 Subject: [PATCH 1/2] fix(policy): populate Cedar resource so resource-scoped policies enforce The AGT CedarBackend builds the request resource from the `resource` key in the evaluation context. The ingress proxy and the egress evaluator never set it, so resource defaulted to Resource::"default" and any policy of the form forbid(principal, action, resource == Resource::""); silently never matched. In enforcing mode that default-denies every call; in advisory mode it forwards every call regardless of policy. Set the resource to the tool name at both ingress and egress. Adds regression tests covering the context shape and end-to-end allow/deny. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmcp_runtime/mcp/proxy.py | 6 ++ src/cmcp_runtime/policy/evaluator.py | 4 + tests/unit/test_resource_scoped_policy.py | 120 ++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 tests/unit/test_resource_scoped_policy.py diff --git a/src/cmcp_runtime/mcp/proxy.py b/src/cmcp_runtime/mcp/proxy.py index 6cefbda..e5610ee 100644 --- a/src/cmcp_runtime/mcp/proxy.py +++ b/src/cmcp_runtime/mcp/proxy.py @@ -375,6 +375,12 @@ def _build_cedar_context( entry = self._catalog.lookup(tool_name) ctx: dict[str, Any] = { "tool_name": tool_name, + # Cedar resource entity: the backend builds Resource::"" from + # this, so policies can match a tool by name, e.g. + # forbid(principal, action, resource == Resource::"salesforce.contacts"); + # Without it the resource defaults to Resource::"default" and no + # resource-scoped policy can ever match. + "resource": tool_name, "arguments": _cedar_safe(arguments), "server_identity": entry.server.url if entry else "", "compliance_domain": entry.compliance_domain if entry else "external", diff --git a/src/cmcp_runtime/policy/evaluator.py b/src/cmcp_runtime/policy/evaluator.py index 1d00267..2261229 100644 --- a/src/cmcp_runtime/policy/evaluator.py +++ b/src/cmcp_runtime/policy/evaluator.py @@ -219,6 +219,10 @@ def authorize_egress( self._maybe_reload() context: dict[str, Any] = { "tool_name": tool_name, + # Same resource entity as ingress (see PolicyProxy._build_cedar_context) + # so a resource-scoped permit matches at egress too; without it an + # allowed tool response would be denied on the way back. + "resource": tool_name, "egress": True, "sensitivity_level": SENSITIVITY_ORDER.get(session.max_sensitivity, 0), "injection_events": len(session.injection_events), diff --git a/tests/unit/test_resource_scoped_policy.py b/tests/unit/test_resource_scoped_policy.py new file mode 100644 index 0000000..7db1e20 --- /dev/null +++ b/tests/unit/test_resource_scoped_policy.py @@ -0,0 +1,120 @@ +"""Resource-scoped Cedar policies must actually enforce. + +Regression guard: the Cedar backend builds the request resource from the +`resource` key in the evaluation context. If the proxy/evaluator omit it, the +resource defaults to Resource::"default" and a policy like + + forbid(principal, action, resource == Resource::"salesforce.contacts"); + +never matches, so every call silently default-denies (or, in advisory mode, +forwards). These tests pin the context shape and the end-to-end decision. +""" + +from __future__ import annotations + +import pytest + +from cmcp_runtime.config import AttestationConfig, Config, EnforcementMode +from cmcp_runtime.errors import PolicyDeny +from cmcp_runtime.policy.bundle import PolicyBundle, PolicyManifest +from cmcp_runtime.policy.evaluator import PolicyEvaluator +from cmcp_runtime.session.state import SessionState +from tests.unit.test_call_log_integration import _make_catalog, _make_proxy + +RESOURCE_POLICY = """ +// Permit calls from the demo-agent workflow. +permit (principal, action, resource) when { context.workflow_id == "demo-agent" }; + +// Block one tool by resource name. forbid overrides permit. +forbid (principal, action, resource == Resource::"salesforce.contacts"); +""" + + +def _bundle(policy_content: str) -> PolicyBundle: + return PolicyBundle( + manifest=PolicyManifest( + version="1.0.0", + authored_at="2026-06-10T00:00:00Z", + author_identity="test", + commit_sha="abc", + ), + policy_files={"demo.cedar": policy_content}, + schema_content='{"cMCP": {}}', + bundle_hash="sha256:" + "0" * 64, + ) + + +def _evaluator(mode: EnforcementMode = EnforcementMode.ENFORCING) -> PolicyEvaluator: + cfg = Config(attestation=AttestationConfig(enforcement_mode=mode)) + return PolicyEvaluator(_bundle(RESOURCE_POLICY), cfg) + + +# ── the proxy must put the tool name in the Cedar resource slot ─────────────── + + +def test_build_cedar_context_sets_resource_to_tool_name(): + proxy, _, _ = _make_proxy(catalog=_make_catalog("salesforce.contacts")) + ctx = proxy._build_cedar_context("salesforce.contacts", {}, "demo-agent") + assert ctx["resource"] == "salesforce.contacts" + + +# ── ingress: resource-scoped forbid actually denies ────────────────────────── + + +def test_resource_forbid_denies_sensitive_tool(): + ev = _evaluator() + ctx = { + "tool_name": "salesforce.contacts", + "resource": "salesforce.contacts", + "session_max_sensitivity": "public", + "workflow_id": "demo-agent", + } + with pytest.raises(PolicyDeny): + ev.evaluate(ctx) + + +def test_permitted_tool_is_allowed(): + ev = _evaluator() + decision = ev.evaluate( + { + "tool_name": "echo", + "resource": "echo", + "session_max_sensitivity": "public", + "workflow_id": "demo-agent", + } + ) + assert decision.allowed is True + assert decision.would_have_denied is False + + +def test_wrong_workflow_is_denied(): + ev = _evaluator() + with pytest.raises(PolicyDeny): + ev.evaluate( + { + "tool_name": "echo", + "resource": "echo", + "session_max_sensitivity": "public", + "workflow_id": "stranger", + } + ) + + +# ── egress: the allowed tool's response must not be denied on the way back ──── + + +def test_egress_allows_permitted_tool(): + ev = _evaluator() + session = SessionState(session_id="sess-egress") + decision = ev.authorize_egress("echo", b"{}", session, workflow_id="demo-agent") + assert decision.allowed is True + assert decision.would_have_denied is False + + +def test_egress_denies_forbidden_resource(): + ev = _evaluator() + session = SessionState(session_id="sess-egress") + with pytest.raises(PolicyDeny): + ev.authorize_egress( + "salesforce.contacts", b"{}", session, workflow_id="demo-agent" + ) From 2ef12d339334dc09938eb9a3253159254ccdb47a Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 23 Jul 2026 13:52:35 -0700 Subject: [PATCH 2/2] fix(runtime): version string, policies/ default, autodetect docstring, session-close error - __version__ 0.1.0 -> 0.3.0 so `cmcp --version` matches the package - default policy_bundle_path policy/ -> policies/ to match the shipped examples - correct the auto-detect probe-order docstring to azure-cvm -> tpm -> sev-snp -> tdx - return a clear, actionable session-close error instead of a bare string Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmcp_runtime/__init__.py | 2 +- src/cmcp_runtime/config.py | 2 +- src/cmcp_runtime/mcp/server.py | 10 +++++++++- src/cmcp_runtime/tee/detect.py | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cmcp_runtime/__init__.py b/src/cmcp_runtime/__init__.py index 2d96b9f..e9a7dc5 100644 --- a/src/cmcp_runtime/__init__.py +++ b/src/cmcp_runtime/__init__.py @@ -1,3 +1,3 @@ """cMCP Runtime: hardware-attested MCP runtime.""" -__version__ = "0.1.0" +__version__ = "0.3.0" diff --git a/src/cmcp_runtime/config.py b/src/cmcp_runtime/config.py index 778dcd7..16c848c 100644 --- a/src/cmcp_runtime/config.py +++ b/src/cmcp_runtime/config.py @@ -67,7 +67,7 @@ class Config: attestation: AttestationConfig = field(default_factory=AttestationConfig) agent_manifest: AgentManifestConfig = field(default_factory=AgentManifestConfig) kill_switch: KillSwitchConfig = field(default_factory=KillSwitchConfig) - policy_bundle_path: str = "policy/" + policy_bundle_path: str = "policies/" catalog_path: str = "catalog.json" listen_addr: str = "0.0.0.0:8443" max_response_size_bytes: int = 2 * 1024 * 1024 # 2MB diff --git a/src/cmcp_runtime/mcp/server.py b/src/cmcp_runtime/mcp/server.py index 3035f83..cfea7ee 100644 --- a/src/cmcp_runtime/mcp/server.py +++ b/src/cmcp_runtime/mcp/server.py @@ -528,7 +528,15 @@ async def _session_close(self, request: Request) -> Response: session_id: str = request.path_params["session_id"] if session_id != self._session.session_id: return JSONResponse( - {"error": f"unknown or already closed session_id={session_id}"}, + { + "error": "session_not_found", + "message": ( + f"No open session with id '{session_id}'. It may already be " + "closed, or you passed the _cmcp.session_id label instead of " + "the internal session id. Look up the internal id via " + "GET /audit/export?session_id=