diff --git a/capiscio_mcp/_proto/capiscio/v1/mcp_pb2.py b/capiscio_mcp/_proto/capiscio/v1/mcp_pb2.py index 4b53ca9..1fa4496 100644 --- a/capiscio_mcp/_proto/capiscio/v1/mcp_pb2.py +++ b/capiscio_mcp/_proto/capiscio/v1/mcp_pb2.py @@ -177,3 +177,75 @@ class HealthResponse: core_version: str = "" proto_version: str = "" version_compatible: bool = True + + +# ============================================================================= +# RFC-005: Policy Decision Messages +# ============================================================================= + +@dataclass +class PolicySubject: + """Subject attributes for policy evaluation.""" + did: str = "" + badge_jti: str = "" + ial: str = "" + trust_level: str = "" + badge_exp: int = 0 + + +@dataclass +class PolicyAction: + """Action attributes for policy evaluation.""" + operation: str = "" + capability_class: str = "" + + +@dataclass +class PolicyResource: + """Resource attributes for policy evaluation.""" + identifier: str = "" + + +@dataclass +class PolicyConfig: + """PEP-level configuration for the policy decision.""" + pdp_endpoint: str = "" + pdp_timeout_ms: int = 0 + enforcement_mode: str = "" + pep_id: str = "" + workspace: str = "" + breakglass_public_key: bytes = b"" + + +@dataclass +class PolicyDecisionRequest: + """Request message for EvaluatePolicyDecision RPC.""" + subject: Optional[PolicySubject] = None + action: Optional[PolicyAction] = None + resource: Optional[PolicyResource] = None + config: Optional[PolicyConfig] = None + breakglass_token: str = "" + + +@dataclass +class MCPObligation: + """Obligation from policy decision.""" + type: str = "" + params_json: str = "" + + +@dataclass +class PolicyDecisionResponse: + """Response from centralized policy decision.""" + decision: str = "" + decision_id: str = "" + reason: str = "" + ttl: int = 0 + obligations: List["MCPObligation"] = field(default_factory=list) + enforcement_mode: str = "" + cache_hit: bool = False + breakglass_override: bool = False + breakglass_jti: str = "" + error_code: str = "" + pdp_latency_ms: int = 0 + txn_id: str = "" diff --git a/capiscio_mcp/_proto/capiscio/v1/mcp_pb2_grpc.py b/capiscio_mcp/_proto/capiscio/v1/mcp_pb2_grpc.py index 7a2a233..df863b6 100644 --- a/capiscio_mcp/_proto/capiscio/v1/mcp_pb2_grpc.py +++ b/capiscio_mcp/_proto/capiscio/v1/mcp_pb2_grpc.py @@ -44,6 +44,18 @@ async def EvaluateToolAccess( """ raise NotImplementedError("Stub - replace with generated code") + async def EvaluatePolicyDecision( + self, + request: "mcp_pb2.PolicyDecisionRequest", + ) -> "mcp_pb2.PolicyDecisionResponse": + """ + Evaluate policy decision (RFC-005). + + Centralized PDP decision logic. Returns policy outcome + including obligations — never raises gRPC errors for PDP issues. + """ + raise NotImplementedError("Stub - replace with generated code") + async def VerifyServerIdentity( self, request: "mcp_pb2.VerifyServerIdentityRequest", @@ -87,6 +99,14 @@ async def EvaluateToolAccess( """Evaluate tool access (RFC-006).""" raise NotImplementedError("Method not implemented!") + async def EvaluatePolicyDecision( + self, + request: "mcp_pb2.PolicyDecisionRequest", + context: "grpc.aio.ServicerContext", + ) -> "mcp_pb2.PolicyDecisionResponse": + """Evaluate policy decision (RFC-005).""" + raise NotImplementedError("Method not implemented!") + async def VerifyServerIdentity( self, request: "mcp_pb2.VerifyServerIdentityRequest", diff --git a/capiscio_mcp/_proto/gen/capiscio/v1/mcp_pb2.py b/capiscio_mcp/_proto/gen/capiscio/v1/mcp_pb2.py index 1cb5121..0cd2360 100644 --- a/capiscio_mcp/_proto/gen/capiscio/v1/mcp_pb2.py +++ b/capiscio_mcp/_proto/gen/capiscio/v1/mcp_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/mcp.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.31.1 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -12,8 +12,8 @@ _runtime_version.ValidateProtobufRuntimeVersion( _runtime_version.Domain.PUBLIC, 6, - 33, - 4, + 31, + 1, '', 'capiscio/v1/mcp.proto' ) @@ -25,48 +25,62 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x63\x61piscio/v1/mcp.proto\x12\x0b\x63\x61piscio.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa9\x02\n\x19\x45valuateToolAccessRequest\x12\x1b\n\ttool_name\x18\x01 \x01(\tR\x08toolName\x12\x1f\n\x0bparams_hash\x18\x02 \x01(\tR\nparamsHash\x12#\n\rserver_origin\x18\x03 \x01(\tR\x0cserverOrigin\x12\x1d\n\tbadge_jws\x18\x04 \x01(\tH\x00R\x08\x62\x61\x64geJws\x12\x19\n\x07\x61pi_key\x18\x05 \x01(\tH\x00R\x06\x61piKey\x12%\n\x0epolicy_version\x18\x06 \x01(\tR\rpolicyVersion\x12\x33\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x1b.capiscio.v1.EvaluateConfigR\x06\x63onfigB\x13\n\x11\x63\x61ller_credential\"\xb2\x01\n\x0e\x45valuateConfig\x12\'\n\x0ftrusted_issuers\x18\x01 \x03(\tR\x0etrustedIssuers\x12&\n\x0fmin_trust_level\x18\x02 \x01(\x05R\rminTrustLevel\x12*\n\x11\x61\x63\x63\x65pt_level_zero\x18\x03 \x01(\x08R\x0f\x61\x63\x63\x65ptLevelZero\x12#\n\rallowed_tools\x18\x04 \x03(\tR\x0c\x61llowedTools\"\xc5\x03\n\x1a\x45valuateToolAccessResponse\x12\x34\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32\x18.capiscio.v1.MCPDecisionR\x08\x64\x65\x63ision\x12;\n\x0b\x64\x65ny_reason\x18\x02 \x01(\x0e\x32\x1a.capiscio.v1.MCPDenyReasonR\ndenyReason\x12\x1f\n\x0b\x64\x65ny_detail\x18\x03 \x01(\tR\ndenyDetail\x12\x1b\n\tagent_did\x18\x04 \x01(\tR\x08\x61gentDid\x12\x1b\n\tbadge_jti\x18\x05 \x01(\tR\x08\x62\x61\x64geJti\x12\x38\n\nauth_level\x18\x06 \x01(\x0e\x32\x19.capiscio.v1.MCPAuthLevelR\tauthLevel\x12\x1f\n\x0btrust_level\x18\x07 \x01(\x05R\ntrustLevel\x12#\n\revidence_json\x18\x08 \x01(\tR\x0c\x65videnceJson\x12\x1f\n\x0b\x65vidence_id\x18\t \x01(\tR\nevidenceId\x12\x38\n\ttimestamp\x18\n \x01(\x0b\x32\x1a.google.protobuf.TimestampR\ttimestamp\"\xe5\x01\n\x1bVerifyServerIdentityRequest\x12\x1d\n\nserver_did\x18\x01 \x01(\tR\tserverDid\x12!\n\x0cserver_badge\x18\x02 \x01(\tR\x0bserverBadge\x12)\n\x10transport_origin\x18\x03 \x01(\tR\x0ftransportOrigin\x12#\n\rendpoint_path\x18\x04 \x01(\tR\x0c\x65ndpointPath\x12\x34\n\x06\x63onfig\x18\x05 \x01(\x0b\x32\x1c.capiscio.v1.MCPVerifyConfigR\x06\x63onfig\"\xe1\x01\n\x0fMCPVerifyConfig\x12\'\n\x0ftrusted_issuers\x18\x01 \x03(\tR\x0etrustedIssuers\x12&\n\x0fmin_trust_level\x18\x02 \x01(\x05R\rminTrustLevel\x12*\n\x11\x61\x63\x63\x65pt_level_zero\x18\x03 \x01(\x08R\x0f\x61\x63\x63\x65ptLevelZero\x12!\n\x0coffline_mode\x18\x04 \x01(\x08R\x0bofflineMode\x12.\n\x13skip_origin_binding\x18\x05 \x01(\x08R\x11skipOriginBinding\"\x91\x02\n\x1cVerifyServerIdentityResponse\x12\x31\n\x05state\x18\x01 \x01(\x0e\x32\x1b.capiscio.v1.MCPServerStateR\x05state\x12\x1f\n\x0btrust_level\x18\x02 \x01(\x05R\ntrustLevel\x12\x1d\n\nserver_did\x18\x03 \x01(\tR\tserverDid\x12\x1b\n\tbadge_jti\x18\x04 \x01(\tR\x08\x62\x61\x64geJti\x12>\n\nerror_code\x18\x05 \x01(\x0e\x32\x1f.capiscio.v1.MCPServerErrorCodeR\terrorCode\x12!\n\x0c\x65rror_detail\x18\x06 \x01(\tR\x0b\x65rrorDetail\"\xaa\x01\n\x1aParseServerIdentityRequest\x12@\n\x0chttp_headers\x18\x01 \x01(\x0b\x32\x1b.capiscio.v1.MCPHttpHeadersH\x00R\x0bhttpHeaders\x12@\n\x0cjsonrpc_meta\x18\x02 \x01(\x0b\x32\x1b.capiscio.v1.MCPJsonRpcMetaH\x00R\x0bjsonrpcMetaB\x08\n\x06source\"t\n\x0eMCPHttpHeaders\x12.\n\x13\x63\x61piscio_server_did\x18\x01 \x01(\tR\x11\x63\x61piscioServerDid\x12\x32\n\x15\x63\x61piscio_server_badge\x18\x02 \x01(\tR\x13\x63\x61piscioServerBadge\"-\n\x0eMCPJsonRpcMeta\x12\x1b\n\tmeta_json\x18\x01 \x01(\tR\x08metaJson\"\x8a\x01\n\x1bParseServerIdentityResponse\x12\x1d\n\nserver_did\x18\x01 \x01(\tR\tserverDid\x12!\n\x0cserver_badge\x18\x02 \x01(\tR\x0bserverBadge\x12)\n\x10identity_present\x18\x03 \x01(\x08R\x0fidentityPresent\"9\n\x10MCPHealthRequest\x12%\n\x0e\x63lient_version\x18\x01 \x01(\tR\rclientVersion\"\xa4\x01\n\x11MCPHealthResponse\x12\x18\n\x07healthy\x18\x01 \x01(\x08R\x07healthy\x12!\n\x0c\x63ore_version\x18\x02 \x01(\tR\x0b\x63oreVersion\x12#\n\rproto_version\x18\x03 \x01(\tR\x0cprotoVersion\x12-\n\x12version_compatible\x18\x04 \x01(\x08R\x11versionCompatible*Z\n\x0bMCPDecision\x12\x1c\n\x18MCP_DECISION_UNSPECIFIED\x10\x00\x12\x16\n\x12MCP_DECISION_ALLOW\x10\x01\x12\x15\n\x11MCP_DECISION_DENY\x10\x02*\x82\x01\n\x0cMCPAuthLevel\x12\x1e\n\x1aMCP_AUTH_LEVEL_UNSPECIFIED\x10\x00\x12\x1c\n\x18MCP_AUTH_LEVEL_ANONYMOUS\x10\x01\x12\x1a\n\x16MCP_AUTH_LEVEL_API_KEY\x10\x02\x12\x18\n\x14MCP_AUTH_LEVEL_BADGE\x10\x03*\xd3\x02\n\rMCPDenyReason\x12\x1f\n\x1bMCP_DENY_REASON_UNSPECIFIED\x10\x00\x12!\n\x1dMCP_DENY_REASON_BADGE_MISSING\x10\x01\x12!\n\x1dMCP_DENY_REASON_BADGE_INVALID\x10\x02\x12!\n\x1dMCP_DENY_REASON_BADGE_EXPIRED\x10\x03\x12!\n\x1dMCP_DENY_REASON_BADGE_REVOKED\x10\x04\x12&\n\"MCP_DENY_REASON_TRUST_INSUFFICIENT\x10\x05\x12$\n MCP_DENY_REASON_TOOL_NOT_ALLOWED\x10\x06\x12$\n MCP_DENY_REASON_ISSUER_UNTRUSTED\x10\x07\x12!\n\x1dMCP_DENY_REASON_POLICY_DENIED\x10\x08*\xac\x01\n\x0eMCPServerState\x12 \n\x1cMCP_SERVER_STATE_UNSPECIFIED\x10\x00\x12\'\n#MCP_SERVER_STATE_VERIFIED_PRINCIPAL\x10\x01\x12\'\n#MCP_SERVER_STATE_DECLARED_PRINCIPAL\x10\x02\x12&\n\"MCP_SERVER_STATE_UNVERIFIED_ORIGIN\x10\x03*\xd7\x02\n\x12MCPServerErrorCode\x12\x19\n\x15MCP_SERVER_ERROR_NONE\x10\x00\x12 \n\x1cMCP_SERVER_ERROR_DID_INVALID\x10\x01\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_INVALID\x10\x02\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_EXPIRED\x10\x03\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_REVOKED\x10\x04\x12\'\n#MCP_SERVER_ERROR_TRUST_INSUFFICIENT\x10\x05\x12$\n MCP_SERVER_ERROR_ORIGIN_MISMATCH\x10\x06\x12\"\n\x1eMCP_SERVER_ERROR_PATH_MISMATCH\x10\x07\x12%\n!MCP_SERVER_ERROR_ISSUER_UNTRUSTED\x10\x08\x32\x93\x03\n\nMCPService\x12\x65\n\x12\x45valuateToolAccess\x12&.capiscio.v1.EvaluateToolAccessRequest\x1a\'.capiscio.v1.EvaluateToolAccessResponse\x12k\n\x14VerifyServerIdentity\x12(.capiscio.v1.VerifyServerIdentityRequest\x1a).capiscio.v1.VerifyServerIdentityResponse\x12h\n\x13ParseServerIdentity\x12\'.capiscio.v1.ParseServerIdentityRequest\x1a(.capiscio.v1.ParseServerIdentityResponse\x12G\n\x06Health\x12\x1d.capiscio.v1.MCPHealthRequest\x1a\x1e.capiscio.v1.MCPHealthResponseB\xae\x01\n\x0f\x63om.capiscio.v1B\x08McpProtoP\x01ZDgithub.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1;capisciov1\xa2\x02\x03\x43XX\xaa\x02\x0b\x43\x61piscio.V1\xca\x02\x0b\x43\x61piscio\\V1\xe2\x02\x17\x43\x61piscio\\V1\\GPBMetadata\xea\x02\x0c\x43\x61piscio::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x63\x61piscio/v1/mcp.proto\x12\x0b\x63\x61piscio.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x80\x03\n\x19\x45valuateToolAccessRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x13\n\x0bparams_hash\x18\x02 \x01(\t\x12\x15\n\rserver_origin\x18\x03 \x01(\t\x12\x13\n\tbadge_jws\x18\x04 \x01(\tH\x00\x12\x11\n\x07\x61pi_key\x18\x05 \x01(\tH\x00\x12\x16\n\x0epolicy_version\x18\x06 \x01(\t\x12+\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x1b.capiscio.v1.EvaluateConfig\x12\x18\n\x10\x65nforcement_mode\x18\x08 \x01(\t\x12\x18\n\x10\x63\x61pability_class\x18\n \x01(\t\x12\x13\n\x0b\x65nvelope_id\x18\x0b \x01(\t\x12\x18\n\x10\x64\x65legation_depth\x18\x0c \x01(\x05\x12\x18\n\x10\x63onstraints_json\x18\r \x01(\t\x12\x1f\n\x17parent_constraints_json\x18\x0e \x01(\tB\x13\n\x11\x63\x61ller_credentialJ\x04\x08\t\x10\n\"t\n\x0e\x45valuateConfig\x12\x17\n\x0ftrusted_issuers\x18\x01 \x03(\t\x12\x17\n\x0fmin_trust_level\x18\x02 \x01(\x05\x12\x19\n\x11\x61\x63\x63\x65pt_level_zero\x18\x03 \x01(\x08\x12\x15\n\rallowed_tools\x18\x04 \x03(\t\"\xd3\x03\n\x1a\x45valuateToolAccessResponse\x12*\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32\x18.capiscio.v1.MCPDecision\x12/\n\x0b\x64\x65ny_reason\x18\x02 \x01(\x0e\x32\x1a.capiscio.v1.MCPDenyReason\x12\x13\n\x0b\x64\x65ny_detail\x18\x03 \x01(\t\x12\x11\n\tagent_did\x18\x04 \x01(\t\x12\x11\n\tbadge_jti\x18\x05 \x01(\t\x12-\n\nauth_level\x18\x06 \x01(\x0e\x32\x19.capiscio.v1.MCPAuthLevel\x12\x13\n\x0btrust_level\x18\x07 \x01(\x05\x12\x15\n\revidence_json\x18\x08 \x01(\t\x12\x13\n\x0b\x65vidence_id\x18\t \x01(\t\x12-\n\ttimestamp\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1a\n\x12policy_decision_id\x18\x0b \x01(\t\x12\x17\n\x0fpolicy_decision\x18\x0c \x01(\t\x12\x18\n\x10\x65nforcement_mode\x18\r \x01(\t\x12/\n\x0bobligations\x18\x0e \x03(\x0b\x32\x1a.capiscio.v1.MCPObligation\"2\n\rMCPObligation\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x13\n\x0bparams_json\x18\x02 \x01(\t\"\xe3\x01\n\x15PolicyDecisionRequest\x12+\n\x07subject\x18\x01 \x01(\x0b\x32\x1a.capiscio.v1.PolicySubject\x12)\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x19.capiscio.v1.PolicyAction\x12-\n\x08resource\x18\x03 \x01(\x0b\x32\x1b.capiscio.v1.PolicyResource\x12)\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x19.capiscio.v1.PolicyConfig\x12\x18\n\x10\x62reakglass_token\x18\x05 \x01(\t\"d\n\rPolicySubject\x12\x0b\n\x03\x64id\x18\x01 \x01(\t\x12\x11\n\tbadge_jti\x18\x02 \x01(\t\x12\x0b\n\x03ial\x18\x03 \x01(\t\x12\x13\n\x0btrust_level\x18\x04 \x01(\t\x12\x11\n\tbadge_exp\x18\x05 \x01(\x03\";\n\x0cPolicyAction\x12\x11\n\toperation\x18\x01 \x01(\t\x12\x18\n\x10\x63\x61pability_class\x18\x02 \x01(\t\"$\n\x0ePolicyResource\x12\x12\n\nidentifier\x18\x01 \x01(\t\"\x98\x01\n\x0cPolicyConfig\x12\x14\n\x0cpdp_endpoint\x18\x01 \x01(\t\x12\x16\n\x0epdp_timeout_ms\x18\x02 \x01(\x05\x12\x18\n\x10\x65nforcement_mode\x18\x03 \x01(\t\x12\x0e\n\x06pep_id\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\x12\x1d\n\x15\x62reakglass_public_key\x18\x06 \x01(\x0c\"\xab\x02\n\x16PolicyDecisionResponse\x12\x10\n\x08\x64\x65\x63ision\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65\x63ision_id\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x0b\n\x03ttl\x18\x04 \x01(\x05\x12/\n\x0bobligations\x18\x05 \x03(\x0b\x32\x1a.capiscio.v1.MCPObligation\x12\x18\n\x10\x65nforcement_mode\x18\x06 \x01(\t\x12\x11\n\tcache_hit\x18\x07 \x01(\x08\x12\x1b\n\x13\x62reakglass_override\x18\x08 \x01(\x08\x12\x16\n\x0e\x62reakglass_jti\x18\t \x01(\t\x12\x12\n\nerror_code\x18\n \x01(\t\x12\x16\n\x0epdp_latency_ms\x18\x0b \x01(\x03\x12\x0e\n\x06txn_id\x18\x0c \x01(\t\"\xa6\x01\n\x1bVerifyServerIdentityRequest\x12\x12\n\nserver_did\x18\x01 \x01(\t\x12\x14\n\x0cserver_badge\x18\x02 \x01(\t\x12\x18\n\x10transport_origin\x18\x03 \x01(\t\x12\x15\n\rendpoint_path\x18\x04 \x01(\t\x12,\n\x06\x63onfig\x18\x05 \x01(\x0b\x32\x1c.capiscio.v1.MCPVerifyConfig\"\x91\x01\n\x0fMCPVerifyConfig\x12\x17\n\x0ftrusted_issuers\x18\x01 \x03(\t\x12\x17\n\x0fmin_trust_level\x18\x02 \x01(\x05\x12\x19\n\x11\x61\x63\x63\x65pt_level_zero\x18\x03 \x01(\x08\x12\x14\n\x0coffline_mode\x18\x04 \x01(\x08\x12\x1b\n\x13skip_origin_binding\x18\x05 \x01(\x08\"\xd1\x01\n\x1cVerifyServerIdentityResponse\x12*\n\x05state\x18\x01 \x01(\x0e\x32\x1b.capiscio.v1.MCPServerState\x12\x13\n\x0btrust_level\x18\x02 \x01(\x05\x12\x12\n\nserver_did\x18\x03 \x01(\t\x12\x11\n\tbadge_jti\x18\x04 \x01(\t\x12\x33\n\nerror_code\x18\x05 \x01(\x0e\x32\x1f.capiscio.v1.MCPServerErrorCode\x12\x14\n\x0c\x65rror_detail\x18\x06 \x01(\t\"\x90\x01\n\x1aParseServerIdentityRequest\x12\x33\n\x0chttp_headers\x18\x01 \x01(\x0b\x32\x1b.capiscio.v1.MCPHttpHeadersH\x00\x12\x33\n\x0cjsonrpc_meta\x18\x02 \x01(\x0b\x32\x1b.capiscio.v1.MCPJsonRpcMetaH\x00\x42\x08\n\x06source\"L\n\x0eMCPHttpHeaders\x12\x1b\n\x13\x63\x61piscio_server_did\x18\x01 \x01(\t\x12\x1d\n\x15\x63\x61piscio_server_badge\x18\x02 \x01(\t\"#\n\x0eMCPJsonRpcMeta\x12\x11\n\tmeta_json\x18\x01 \x01(\t\"a\n\x1bParseServerIdentityResponse\x12\x12\n\nserver_did\x18\x01 \x01(\t\x12\x14\n\x0cserver_badge\x18\x02 \x01(\t\x12\x18\n\x10identity_present\x18\x03 \x01(\x08\"*\n\x10MCPHealthRequest\x12\x16\n\x0e\x63lient_version\x18\x01 \x01(\t\"m\n\x11MCPHealthResponse\x12\x0f\n\x07healthy\x18\x01 \x01(\x08\x12\x14\n\x0c\x63ore_version\x18\x02 \x01(\t\x12\x15\n\rproto_version\x18\x03 \x01(\t\x12\x1a\n\x12version_compatible\x18\x04 \x01(\x08*Z\n\x0bMCPDecision\x12\x1c\n\x18MCP_DECISION_UNSPECIFIED\x10\x00\x12\x16\n\x12MCP_DECISION_ALLOW\x10\x01\x12\x15\n\x11MCP_DECISION_DENY\x10\x02*\x82\x01\n\x0cMCPAuthLevel\x12\x1e\n\x1aMCP_AUTH_LEVEL_UNSPECIFIED\x10\x00\x12\x1c\n\x18MCP_AUTH_LEVEL_ANONYMOUS\x10\x01\x12\x1a\n\x16MCP_AUTH_LEVEL_API_KEY\x10\x02\x12\x18\n\x14MCP_AUTH_LEVEL_BADGE\x10\x03*\xd3\x02\n\rMCPDenyReason\x12\x1f\n\x1bMCP_DENY_REASON_UNSPECIFIED\x10\x00\x12!\n\x1dMCP_DENY_REASON_BADGE_MISSING\x10\x01\x12!\n\x1dMCP_DENY_REASON_BADGE_INVALID\x10\x02\x12!\n\x1dMCP_DENY_REASON_BADGE_EXPIRED\x10\x03\x12!\n\x1dMCP_DENY_REASON_BADGE_REVOKED\x10\x04\x12&\n\"MCP_DENY_REASON_TRUST_INSUFFICIENT\x10\x05\x12$\n MCP_DENY_REASON_TOOL_NOT_ALLOWED\x10\x06\x12$\n MCP_DENY_REASON_ISSUER_UNTRUSTED\x10\x07\x12!\n\x1dMCP_DENY_REASON_POLICY_DENIED\x10\x08*\xac\x01\n\x0eMCPServerState\x12 \n\x1cMCP_SERVER_STATE_UNSPECIFIED\x10\x00\x12\'\n#MCP_SERVER_STATE_VERIFIED_PRINCIPAL\x10\x01\x12\'\n#MCP_SERVER_STATE_DECLARED_PRINCIPAL\x10\x02\x12&\n\"MCP_SERVER_STATE_UNVERIFIED_ORIGIN\x10\x03*\xd7\x02\n\x12MCPServerErrorCode\x12\x19\n\x15MCP_SERVER_ERROR_NONE\x10\x00\x12 \n\x1cMCP_SERVER_ERROR_DID_INVALID\x10\x01\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_INVALID\x10\x02\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_EXPIRED\x10\x03\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_REVOKED\x10\x04\x12\'\n#MCP_SERVER_ERROR_TRUST_INSUFFICIENT\x10\x05\x12$\n MCP_SERVER_ERROR_ORIGIN_MISMATCH\x10\x06\x12\"\n\x1eMCP_SERVER_ERROR_PATH_MISMATCH\x10\x07\x12%\n!MCP_SERVER_ERROR_ISSUER_UNTRUSTED\x10\x08\x32\xf6\x03\n\nMCPService\x12\x65\n\x12\x45valuateToolAccess\x12&.capiscio.v1.EvaluateToolAccessRequest\x1a\'.capiscio.v1.EvaluateToolAccessResponse\x12\x61\n\x16\x45valuatePolicyDecision\x12\".capiscio.v1.PolicyDecisionRequest\x1a#.capiscio.v1.PolicyDecisionResponse\x12k\n\x14VerifyServerIdentity\x12(.capiscio.v1.VerifyServerIdentityRequest\x1a).capiscio.v1.VerifyServerIdentityResponse\x12h\n\x13ParseServerIdentity\x12\'.capiscio.v1.ParseServerIdentityRequest\x1a(.capiscio.v1.ParseServerIdentityResponse\x12G\n\x06Health\x12\x1d.capiscio.v1.MCPHealthRequest\x1a\x1e.capiscio.v1.MCPHealthResponseB;Z9github.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'capiscio.v1.mcp_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None - _globals['DESCRIPTOR']._serialized_options = b'\n\017com.capiscio.v1B\010McpProtoP\001ZDgithub.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1;capisciov1\242\002\003CXX\252\002\013Capiscio.V1\312\002\013Capiscio\\V1\342\002\027Capiscio\\V1\\GPBMetadata\352\002\014Capiscio::V1' - _globals['_MCPDECISION']._serialized_start=2449 - _globals['_MCPDECISION']._serialized_end=2539 - _globals['_MCPAUTHLEVEL']._serialized_start=2542 - _globals['_MCPAUTHLEVEL']._serialized_end=2672 - _globals['_MCPDENYREASON']._serialized_start=2675 - _globals['_MCPDENYREASON']._serialized_end=3014 - _globals['_MCPSERVERSTATE']._serialized_start=3017 - _globals['_MCPSERVERSTATE']._serialized_end=3189 - _globals['_MCPSERVERERRORCODE']._serialized_start=3192 - _globals['_MCPSERVERERRORCODE']._serialized_end=3535 + _globals['DESCRIPTOR']._serialized_options = b'Z9github.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1' + _globals['_MCPDECISION']._serialized_start=3031 + _globals['_MCPDECISION']._serialized_end=3121 + _globals['_MCPAUTHLEVEL']._serialized_start=3124 + _globals['_MCPAUTHLEVEL']._serialized_end=3254 + _globals['_MCPDENYREASON']._serialized_start=3257 + _globals['_MCPDENYREASON']._serialized_end=3596 + _globals['_MCPSERVERSTATE']._serialized_start=3599 + _globals['_MCPSERVERSTATE']._serialized_end=3771 + _globals['_MCPSERVERERRORCODE']._serialized_start=3774 + _globals['_MCPSERVERERRORCODE']._serialized_end=4117 _globals['_EVALUATETOOLACCESSREQUEST']._serialized_start=72 - _globals['_EVALUATETOOLACCESSREQUEST']._serialized_end=369 - _globals['_EVALUATECONFIG']._serialized_start=372 - _globals['_EVALUATECONFIG']._serialized_end=550 - _globals['_EVALUATETOOLACCESSRESPONSE']._serialized_start=553 - _globals['_EVALUATETOOLACCESSRESPONSE']._serialized_end=1006 - _globals['_VERIFYSERVERIDENTITYREQUEST']._serialized_start=1009 - _globals['_VERIFYSERVERIDENTITYREQUEST']._serialized_end=1238 - _globals['_MCPVERIFYCONFIG']._serialized_start=1241 - _globals['_MCPVERIFYCONFIG']._serialized_end=1466 - _globals['_VERIFYSERVERIDENTITYRESPONSE']._serialized_start=1469 - _globals['_VERIFYSERVERIDENTITYRESPONSE']._serialized_end=1742 - _globals['_PARSESERVERIDENTITYREQUEST']._serialized_start=1745 - _globals['_PARSESERVERIDENTITYREQUEST']._serialized_end=1915 - _globals['_MCPHTTPHEADERS']._serialized_start=1917 - _globals['_MCPHTTPHEADERS']._serialized_end=2033 - _globals['_MCPJSONRPCMETA']._serialized_start=2035 - _globals['_MCPJSONRPCMETA']._serialized_end=2080 - _globals['_PARSESERVERIDENTITYRESPONSE']._serialized_start=2083 - _globals['_PARSESERVERIDENTITYRESPONSE']._serialized_end=2221 - _globals['_MCPHEALTHREQUEST']._serialized_start=2223 - _globals['_MCPHEALTHREQUEST']._serialized_end=2280 - _globals['_MCPHEALTHRESPONSE']._serialized_start=2283 - _globals['_MCPHEALTHRESPONSE']._serialized_end=2447 - _globals['_MCPSERVICE']._serialized_start=3538 - _globals['_MCPSERVICE']._serialized_end=3941 + _globals['_EVALUATETOOLACCESSREQUEST']._serialized_end=456 + _globals['_EVALUATECONFIG']._serialized_start=458 + _globals['_EVALUATECONFIG']._serialized_end=574 + _globals['_EVALUATETOOLACCESSRESPONSE']._serialized_start=577 + _globals['_EVALUATETOOLACCESSRESPONSE']._serialized_end=1044 + _globals['_MCPOBLIGATION']._serialized_start=1046 + _globals['_MCPOBLIGATION']._serialized_end=1096 + _globals['_POLICYDECISIONREQUEST']._serialized_start=1099 + _globals['_POLICYDECISIONREQUEST']._serialized_end=1326 + _globals['_POLICYSUBJECT']._serialized_start=1328 + _globals['_POLICYSUBJECT']._serialized_end=1428 + _globals['_POLICYACTION']._serialized_start=1430 + _globals['_POLICYACTION']._serialized_end=1489 + _globals['_POLICYRESOURCE']._serialized_start=1491 + _globals['_POLICYRESOURCE']._serialized_end=1527 + _globals['_POLICYCONFIG']._serialized_start=1530 + _globals['_POLICYCONFIG']._serialized_end=1682 + _globals['_POLICYDECISIONRESPONSE']._serialized_start=1685 + _globals['_POLICYDECISIONRESPONSE']._serialized_end=1984 + _globals['_VERIFYSERVERIDENTITYREQUEST']._serialized_start=1987 + _globals['_VERIFYSERVERIDENTITYREQUEST']._serialized_end=2153 + _globals['_MCPVERIFYCONFIG']._serialized_start=2156 + _globals['_MCPVERIFYCONFIG']._serialized_end=2301 + _globals['_VERIFYSERVERIDENTITYRESPONSE']._serialized_start=2304 + _globals['_VERIFYSERVERIDENTITYRESPONSE']._serialized_end=2513 + _globals['_PARSESERVERIDENTITYREQUEST']._serialized_start=2516 + _globals['_PARSESERVERIDENTITYREQUEST']._serialized_end=2660 + _globals['_MCPHTTPHEADERS']._serialized_start=2662 + _globals['_MCPHTTPHEADERS']._serialized_end=2738 + _globals['_MCPJSONRPCMETA']._serialized_start=2740 + _globals['_MCPJSONRPCMETA']._serialized_end=2775 + _globals['_PARSESERVERIDENTITYRESPONSE']._serialized_start=2777 + _globals['_PARSESERVERIDENTITYRESPONSE']._serialized_end=2874 + _globals['_MCPHEALTHREQUEST']._serialized_start=2876 + _globals['_MCPHEALTHREQUEST']._serialized_end=2918 + _globals['_MCPHEALTHRESPONSE']._serialized_start=2920 + _globals['_MCPHEALTHRESPONSE']._serialized_end=3029 + _globals['_MCPSERVICE']._serialized_start=4120 + _globals['_MCPSERVICE']._serialized_end=4622 # @@protoc_insertion_point(module_scope) diff --git a/capiscio_mcp/_proto/gen/capiscio/v1/mcp_pb2_grpc.py b/capiscio_mcp/_proto/gen/capiscio/v1/mcp_pb2_grpc.py index 97f4287..8cebf1c 100644 --- a/capiscio_mcp/_proto/gen/capiscio/v1/mcp_pb2_grpc.py +++ b/capiscio_mcp/_proto/gen/capiscio/v1/mcp_pb2_grpc.py @@ -1,12 +1,33 @@ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc +import warnings from capiscio_mcp._proto.gen.capiscio.v1 import mcp_pb2 as capiscio_dot_v1_dot_mcp__pb2 +GRPC_GENERATED_VERSION = '1.76.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + warnings.warn( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in capiscio/v1/mcp_pb2_grpc.py was generated with' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + ' The code may still work, but it is recommended to upgrade your grpc' + + f' module to grpcio>={GRPC_GENERATED_VERSION} to ensure compatibility.', + RuntimeWarning, + ) + class MCPServiceStub(object): - """MCPService provides unified MCP security operations (RFC-006 + RFC-007) + """MCPService provides unified MCP security operations (RFC-005, RFC-006, RFC-007) """ def __init__(self, channel): @@ -20,6 +41,11 @@ def __init__(self, channel): request_serializer=capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessRequest.SerializeToString, response_deserializer=capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessResponse.FromString, _registered_method=True) + self.EvaluatePolicyDecision = channel.unary_unary( + '/capiscio.v1.MCPService/EvaluatePolicyDecision', + request_serializer=capiscio_dot_v1_dot_mcp__pb2.PolicyDecisionRequest.SerializeToString, + response_deserializer=capiscio_dot_v1_dot_mcp__pb2.PolicyDecisionResponse.FromString, + _registered_method=True) self.VerifyServerIdentity = channel.unary_unary( '/capiscio.v1.MCPService/VerifyServerIdentity', request_serializer=capiscio_dot_v1_dot_mcp__pb2.VerifyServerIdentityRequest.SerializeToString, @@ -38,7 +64,7 @@ def __init__(self, channel): class MCPServiceServicer(object): - """MCPService provides unified MCP security operations (RFC-006 + RFC-007) + """MCPService provides unified MCP security operations (RFC-005, RFC-006, RFC-007) """ def EvaluateToolAccess(self, request, context): @@ -49,6 +75,18 @@ def EvaluateToolAccess(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def EvaluatePolicyDecision(self, request, context): + """RFC-005: Centralized policy decision via PDP + Go core owns decision logic, cache, break-glass, telemetry. + SDK callers own obligation execution and response propagation. + NEVER returns an RPC error for PDP unreachability — encodes the outcome + in the response (ALLOW_OBSERVE + error_code) so SDKs don't need to + distinguish transport errors from policy outcomes. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def VerifyServerIdentity(self, request, context): """RFC-007: Verify server identity from disclosed DID + badge """ @@ -78,6 +116,11 @@ def add_MCPServiceServicer_to_server(servicer, server): request_deserializer=capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessRequest.FromString, response_serializer=capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessResponse.SerializeToString, ), + 'EvaluatePolicyDecision': grpc.unary_unary_rpc_method_handler( + servicer.EvaluatePolicyDecision, + request_deserializer=capiscio_dot_v1_dot_mcp__pb2.PolicyDecisionRequest.FromString, + response_serializer=capiscio_dot_v1_dot_mcp__pb2.PolicyDecisionResponse.SerializeToString, + ), 'VerifyServerIdentity': grpc.unary_unary_rpc_method_handler( servicer.VerifyServerIdentity, request_deserializer=capiscio_dot_v1_dot_mcp__pb2.VerifyServerIdentityRequest.FromString, @@ -102,7 +145,7 @@ def add_MCPServiceServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class MCPService(object): - """MCPService provides unified MCP security operations (RFC-006 + RFC-007) + """MCPService provides unified MCP security operations (RFC-005, RFC-006, RFC-007) """ @staticmethod @@ -132,6 +175,33 @@ def EvaluateToolAccess(request, metadata, _registered_method=True) + @staticmethod + def EvaluatePolicyDecision(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/capiscio.v1.MCPService/EvaluatePolicyDecision', + capiscio_dot_v1_dot_mcp__pb2.PolicyDecisionRequest.SerializeToString, + capiscio_dot_v1_dot_mcp__pb2.PolicyDecisionResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def VerifyServerIdentity(request, target, diff --git a/capiscio_mcp/pip.py b/capiscio_mcp/pip.py new file mode 100644 index 0000000..a4d0f99 --- /dev/null +++ b/capiscio_mcp/pip.py @@ -0,0 +1,280 @@ +""" +RFC-005: Policy Integration Point (PIP) — Python thin client. + +Delegates all PDP decision logic (query, cache, break-glass, enforcement mode) +to the Go core's EvaluatePolicyDecision gRPC RPC. The SDK handles: +- Building the request from badge claims and config +- Obligation execution (context-dependent: rate limiting, logging, etc.) +- Response propagation to the calling application + +Architecture: Option B — centralised PDP logic in Go core. +See capiscio-core/internal/rpc/policy_decision.go for the authoritative +decision engine. + +Usage: + from capiscio_mcp.pip import PolicyClient, PIPConfig, PolicyResult + + config = PIPConfig( + pdp_endpoint="https://pdp.example.com/v1/evaluate", + enforcement_mode="EM-GUARD", + ) + client = PolicyClient(config) + result = await client.evaluate( + subject_did="did:web:example.com:agents:bot", + badge_jti="badge-123", + trust_level="2", + badge_exp=1750000000, + operation="tools/call", + resource="database://prod/users", + ) + if result.decision == "ALLOW": + await result.execute_obligations() +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Callable, Coroutine, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class PIPConfig: + """Configuration for the Policy Integration Point. + + Attributes: + pdp_endpoint: PDP URL. Empty = badge-only mode (no PDP query). + pdp_timeout_ms: PDP query timeout in milliseconds. 0 = 500ms default. + enforcement_mode: EM-OBSERVE, EM-GUARD, EM-DELEGATE, EM-STRICT. + Empty = EM-OBSERVE. + pep_id: PEP identifier for audit logs. + workspace: Workspace identifier for PDP requests. + breakglass_public_key: Ed25519 public key bytes for break-glass verification. + """ + + pdp_endpoint: str = "" + pdp_timeout_ms: int = 0 + enforcement_mode: str = "" + pep_id: str = "" + workspace: str = "" + breakglass_public_key: bytes = b"" + + +@dataclass +class Obligation: + """A single obligation returned by the PDP. + + Attributes: + type: Obligation type (e.g., "rate_limit", "audit_log"). + params: Parsed parameters dictionary (from params_json). + """ + + type: str + params: Dict[str, Any] = field(default_factory=dict) + + +# Type alias for obligation handler functions +ObligationHandler = Callable[[Obligation], Coroutine[Any, Any, None]] + + +@dataclass +class PolicyResult: + """Result from a policy decision evaluation. + + Attributes: + decision: "ALLOW", "DENY", or "ALLOW_OBSERVE". + decision_id: Unique ID for this decision (from PDP or synthetic). + reason: Human-readable reason (on DENY or PDP-provided). + ttl: Cache TTL in seconds from PDP. + obligations: List of obligations the SDK should execute. + enforcement_mode: The enforcement mode applied. + cache_hit: Whether the decision came from cache. + breakglass_override: Whether break-glass was applied. + breakglass_jti: Break-glass token JTI (for audit). + error_code: Non-empty when PDP was not consulted + ("pdp_unavailable", "pdp_timeout", "pdp_invalid_response"). + pdp_latency_ms: PDP query time in milliseconds. + txn_id: Transaction ID (UUID v7) for correlation. + """ + + decision: str = "" + decision_id: str = "" + reason: str = "" + ttl: int = 0 + obligations: List[Obligation] = field(default_factory=list) + enforcement_mode: str = "" + cache_hit: bool = False + breakglass_override: bool = False + breakglass_jti: str = "" + error_code: str = "" + pdp_latency_ms: int = 0 + txn_id: str = "" + + @property + def allowed(self) -> bool: + """Whether the decision permits execution (ALLOW or ALLOW_OBSERVE).""" + return self.decision in ("ALLOW", "ALLOW_OBSERVE") + + @property + def denied(self) -> bool: + """Whether the decision denies execution.""" + return self.decision == "DENY" + + @property + def pdp_error(self) -> bool: + """Whether the PDP could not be consulted.""" + return bool(self.error_code) + + async def execute_obligations( + self, + handlers: Optional[Dict[str, ObligationHandler]] = None, + ) -> None: + """Execute obligations returned by the policy decision. + + Obligation execution is context-dependent and stays in the SDK. + For example, rate limiting needs the SDK's HTTP layer, logging + needs the SDK's logger. + + Args: + handlers: Map of obligation type to async handler function. + Unknown obligation types are logged and skipped. + """ + if not self.obligations: + return + + effective_handlers = handlers or {} + + for obligation in self.obligations: + handler = effective_handlers.get(obligation.type) + if handler is None: + logger.warning( + "No handler for obligation type %r, skipping", + obligation.type, + ) + continue + + try: + await handler(obligation) + except Exception: + logger.exception( + "Obligation handler failed for %r", + obligation.type, + ) + + +class PolicyClient: + """Thin client for policy evaluation via capiscio-core gRPC. + + All decision logic (PDP query, caching, break-glass, enforcement mode) + lives in the Go core. This client builds the request, sends it via gRPC, + and returns a ``PolicyResult`` that the SDK can act on. + + Attributes: + config: PIP configuration. + """ + + def __init__(self, config: Optional[PIPConfig] = None) -> None: + self.config = config or PIPConfig() + + async def evaluate( + self, + *, + subject_did: str = "", + badge_jti: str = "", + ial: str = "", + trust_level: str = "", + badge_exp: int = 0, + operation: str = "", + capability_class: str = "", + resource: str = "", + breakglass_token: str = "", + ) -> PolicyResult: + """Evaluate a policy decision via the Go core. + + Args: + subject_did: Agent DID from verified badge. + badge_jti: Badge JTI. + ial: Badge IAL. + trust_level: Badge trust level ("1", "2", etc.). + badge_exp: Badge expiration (Unix seconds). + operation: Tool name, HTTP method+route, etc. + capability_class: Empty in badge-only mode (RFC-008). + resource: Target resource URI. + breakglass_token: Optional break-glass JWS token. + + Returns: + PolicyResult with decision, obligations, and metadata. + """ + from capiscio_mcp._core.client import CoreClient + from capiscio_mcp._proto.capiscio.v1 import mcp_pb2 + + client = await CoreClient.get_instance() + + request = mcp_pb2.PolicyDecisionRequest( + subject=mcp_pb2.PolicySubject( + did=subject_did, + badge_jti=badge_jti, + ial=ial, + trust_level=trust_level, + badge_exp=badge_exp, + ), + action=mcp_pb2.PolicyAction( + operation=operation, + capability_class=capability_class, + ), + resource=mcp_pb2.PolicyResource( + identifier=resource, + ), + config=mcp_pb2.PolicyConfig( + pdp_endpoint=self.config.pdp_endpoint, + pdp_timeout_ms=self.config.pdp_timeout_ms, + enforcement_mode=self.config.enforcement_mode, + pep_id=self.config.pep_id, + workspace=self.config.workspace, + breakglass_public_key=self.config.breakglass_public_key, + ), + breakglass_token=breakglass_token, + ) + + response = await client.stub.EvaluatePolicyDecision(request) + + # Parse obligations + obligations: List[Obligation] = [] + for obl in response.obligations: + params: Dict[str, Any] = {} + if obl.params_json: + try: + parsed = json.loads(obl.params_json) + if isinstance(parsed, dict): + params = parsed + else: + logger.warning( + "Obligation params_json is not a dict for type %s", + obl.type, + ) + except (json.JSONDecodeError, TypeError): + logger.warning( + "Failed to parse obligation params_json for type %s", + obl.type, + ) + obligations.append( + Obligation(type=obl.type, params=params) + ) + + return PolicyResult( + decision=response.decision, + decision_id=response.decision_id, + reason=response.reason, + ttl=response.ttl, + obligations=obligations, + enforcement_mode=response.enforcement_mode, + cache_hit=response.cache_hit, + breakglass_override=response.breakglass_override, + breakglass_jti=response.breakglass_jti, + error_code=response.error_code, + pdp_latency_ms=response.pdp_latency_ms, + txn_id=response.txn_id, + ) diff --git a/tests/test_pip.py b/tests/test_pip.py new file mode 100644 index 0000000..7ef4c85 --- /dev/null +++ b/tests/test_pip.py @@ -0,0 +1,410 @@ +"""Tests for capiscio_mcp.pip module (RFC-005 Policy Integration Point).""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from capiscio_mcp.pip import ( + Obligation, + ObligationHandler, + PIPConfig, + PolicyClient, + PolicyResult, +) + + +# --------------------------------------------------------------------------- +# PolicyResult unit tests +# --------------------------------------------------------------------------- + + +class TestPolicyResult: + """Tests for PolicyResult dataclass.""" + + def test_allowed_allow(self): + r = PolicyResult(decision="ALLOW") + assert r.allowed is True + assert r.denied is False + + def test_allowed_observe(self): + r = PolicyResult(decision="ALLOW_OBSERVE") + assert r.allowed is True + assert r.denied is False + + def test_denied(self): + r = PolicyResult(decision="DENY") + assert r.allowed is False + assert r.denied is True + + def test_pdp_error(self): + r = PolicyResult(decision="ALLOW_OBSERVE", error_code="pdp_unavailable") + assert r.pdp_error is True + assert r.allowed is True + + def test_no_pdp_error(self): + r = PolicyResult(decision="ALLOW") + assert r.pdp_error is False + + +class TestObligation: + """Tests for Obligation dataclass.""" + + def test_basic(self): + o = Obligation(type="rate_limit", params={"max_rps": 10}) + assert o.type == "rate_limit" + assert o.params["max_rps"] == 10 + + def test_empty_params(self): + o = Obligation(type="audit_log") + assert o.params == {} + + +class TestPIPConfig: + """Tests for PIPConfig dataclass.""" + + def test_defaults(self): + c = PIPConfig() + assert c.pdp_endpoint == "" + assert c.pdp_timeout_ms == 0 + assert c.enforcement_mode == "" + assert c.pep_id == "" + + def test_custom(self): + c = PIPConfig( + pdp_endpoint="https://pdp.example.com/eval", + enforcement_mode="EM-GUARD", + pdp_timeout_ms=1000, + pep_id="pep-42", + workspace="ws-1", + breakglass_public_key=b"test-key-bytes", + ) + assert c.pdp_endpoint == "https://pdp.example.com/eval" + assert c.enforcement_mode == "EM-GUARD" + assert c.pdp_timeout_ms == 1000 + + +# --------------------------------------------------------------------------- +# PolicyResult.execute_obligations tests +# --------------------------------------------------------------------------- + + +class TestExecuteObligations: + """Tests for obligation execution on PolicyResult.""" + + @pytest.mark.asyncio + async def test_no_obligations(self): + r = PolicyResult(decision="ALLOW") + await r.execute_obligations() # Should not raise + + @pytest.mark.asyncio + async def test_handler_called(self): + handler = AsyncMock() + obl = Obligation(type="rate_limit", params={"max_rps": 5}) + r = PolicyResult(decision="ALLOW", obligations=[obl]) + + await r.execute_obligations(handlers={"rate_limit": handler}) + + handler.assert_awaited_once_with(obl) + + @pytest.mark.asyncio + async def test_unknown_type_skipped(self): + obl = Obligation(type="unknown_type") + r = PolicyResult(decision="ALLOW", obligations=[obl]) + + # Should not raise — just logs a warning + await r.execute_obligations(handlers={}) + + @pytest.mark.asyncio + async def test_handler_exception_logged(self): + handler = AsyncMock(side_effect=RuntimeError("boom")) + obl = Obligation(type="rate_limit") + r = PolicyResult(decision="ALLOW", obligations=[obl]) + + # Should not raise — logs the exception + await r.execute_obligations(handlers={"rate_limit": handler}) + + @pytest.mark.asyncio + async def test_multiple_obligations(self): + rate_handler = AsyncMock() + audit_handler = AsyncMock() + + obligations = [ + Obligation(type="rate_limit", params={"max_rps": 10}), + Obligation(type="audit_log", params={"detail": "high"}), + ] + r = PolicyResult(decision="ALLOW", obligations=obligations) + + await r.execute_obligations( + handlers={"rate_limit": rate_handler, "audit_log": audit_handler} + ) + + rate_handler.assert_awaited_once() + audit_handler.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# PolicyClient.evaluate tests (mock gRPC) +# --------------------------------------------------------------------------- + + +def _mock_response(**overrides): + """Build a mock PolicyDecisionResponse.""" + defaults = dict( + decision="ALLOW", + decision_id="dec-001", + reason="", + ttl=0, + obligations=[], + enforcement_mode="EM-GUARD", + cache_hit=False, + breakglass_override=False, + breakglass_jti="", + error_code="", + pdp_latency_ms=12, + txn_id="txn-abc", + ) + defaults.update(overrides) + resp = MagicMock() + for k, v in defaults.items(): + setattr(resp, k, v) + return resp + + +class TestPolicyClient: + """Tests for PolicyClient.evaluate via mock gRPC.""" + + @pytest.mark.asyncio + async def test_allow(self, mock_core_client): + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + return_value=_mock_response() + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient(PIPConfig(pdp_endpoint="http://pdp:8080")) + result = await client.evaluate( + subject_did="did:web:example.com:agents:bot", + badge_jti="badge-1", + trust_level="2", + badge_exp=9999999999, + operation="tools/call", + resource="db://prod/users", + ) + + assert result.decision == "ALLOW" + assert result.decision_id == "dec-001" + assert result.txn_id == "txn-abc" + assert result.allowed is True + + @pytest.mark.asyncio + async def test_deny(self, mock_core_client): + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + return_value=_mock_response( + decision="DENY", + decision_id="dec-002", + reason="insufficient trust", + ) + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient(PIPConfig(pdp_endpoint="http://pdp:8080")) + result = await client.evaluate( + subject_did="did:web:example.com:agents:bot", + operation="tools/call", + ) + + assert result.denied is True + assert result.reason == "insufficient trust" + + @pytest.mark.asyncio + async def test_allow_observe_pdp_unavailable(self, mock_core_client): + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + return_value=_mock_response( + decision="ALLOW_OBSERVE", + decision_id="pdp-unavailable", + error_code="pdp_unavailable", + enforcement_mode="EM-OBSERVE", + ) + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient( + PIPConfig( + pdp_endpoint="http://pdp:8080", + enforcement_mode="EM-OBSERVE", + ) + ) + result = await client.evaluate(operation="tools/call") + + assert result.allowed is True + assert result.pdp_error is True + assert result.error_code == "pdp_unavailable" + assert result.enforcement_mode == "EM-OBSERVE" + + @pytest.mark.asyncio + async def test_obligations_parsed(self, mock_core_client): + obl = MagicMock() + obl.type = "rate_limit" + obl.params_json = json.dumps({"max_rps": 10}) + + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + return_value=_mock_response(obligations=[obl]) + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient(PIPConfig(pdp_endpoint="http://pdp:8080")) + result = await client.evaluate(operation="tools/call") + + assert len(result.obligations) == 1 + assert result.obligations[0].type == "rate_limit" + assert result.obligations[0].params == {"max_rps": 10} + + @pytest.mark.asyncio + async def test_obligation_bad_json(self, mock_core_client): + obl = MagicMock() + obl.type = "audit_log" + obl.params_json = "not-json" + + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + return_value=_mock_response(obligations=[obl]) + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient(PIPConfig(pdp_endpoint="http://pdp:8080")) + result = await client.evaluate(operation="tools/call") + + assert len(result.obligations) == 1 + assert result.obligations[0].params == {} + + @pytest.mark.asyncio + async def test_cache_hit(self, mock_core_client): + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + return_value=_mock_response( + cache_hit=True, decision_id="cache-hit" + ) + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient(PIPConfig(pdp_endpoint="http://pdp:8080")) + result = await client.evaluate(operation="tools/call") + + assert result.cache_hit is True + + @pytest.mark.asyncio + async def test_breakglass_override(self, mock_core_client): + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + return_value=_mock_response( + decision="ALLOW", + breakglass_override=True, + breakglass_jti="bg-token-1", + decision_id="breakglass-override", + ) + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient(PIPConfig(pdp_endpoint="http://pdp:8080")) + result = await client.evaluate( + operation="tools/call", + breakglass_token="eyJ...", + ) + + assert result.breakglass_override is True + assert result.breakglass_jti == "bg-token-1" + + @pytest.mark.asyncio + async def test_no_pdp_endpoint(self, mock_core_client): + """Badge-only mode: no PDP endpoint configured.""" + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + return_value=_mock_response( + decision="ALLOW", + decision_id="no-pdp", + enforcement_mode="EM-GUARD", + ) + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient() # No config = badge-only + result = await client.evaluate( + subject_did="did:web:example.com:agents:bot", + operation="tools/call", + ) + + assert result.allowed is True + + @pytest.mark.asyncio + async def test_request_fields_sent(self, mock_core_client): + """Verify the gRPC request includes all configured fields.""" + captured = {} + + async def capture_request(req): + captured["subject_did"] = req.subject.did + captured["badge_jti"] = req.subject.badge_jti + captured["trust_level"] = req.subject.trust_level + captured["badge_exp"] = req.subject.badge_exp + captured["operation"] = req.action.operation + captured["resource"] = req.resource.identifier + captured["pdp_endpoint"] = req.config.pdp_endpoint + captured["enforcement_mode"] = req.config.enforcement_mode + captured["pep_id"] = req.config.pep_id + captured["breakglass_token"] = req.breakglass_token + return _mock_response() + + mock_core_client.stub.EvaluatePolicyDecision = AsyncMock( + side_effect=capture_request + ) + + with patch( + "capiscio_mcp._core.client.CoreClient.get_instance", + return_value=mock_core_client, + ): + client = PolicyClient( + PIPConfig( + pdp_endpoint="http://pdp:8080/eval", + enforcement_mode="EM-GUARD", + pep_id="pep-99", + ) + ) + await client.evaluate( + subject_did="did:web:ex.com:agents:a1", + badge_jti="b-42", + trust_level="3", + badge_exp=1750000000, + operation="tools/call", + resource="db://prod/users", + breakglass_token="bg-tok", + ) + + assert captured["subject_did"] == "did:web:ex.com:agents:a1" + assert captured["badge_jti"] == "b-42" + assert captured["trust_level"] == "3" + assert captured["badge_exp"] == 1750000000 + assert captured["operation"] == "tools/call" + assert captured["resource"] == "db://prod/users" + assert captured["pdp_endpoint"] == "http://pdp:8080/eval" + assert captured["enforcement_mode"] == "EM-GUARD" + assert captured["pep_id"] == "pep-99" + assert captured["breakglass_token"] == "bg-tok"