From 55cb51a0436054df42053996a53f0da8b86a0886 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:34:38 +0000 Subject: [PATCH 1/5] Add more content types to purview handling --- .../src/Microsoft.Agents.AI.Purview/README.md | 36 +++ .../ScopedContentProcessor.cs | 167 +++++++++++-- .../ScopedContentProcessorTests.cs | 194 ++++++++++++++ python/packages/purview/README.md | 40 ++- .../agent_framework_purview/_models.py | 1 + .../agent_framework_purview/_processor.py | 160 ++++++++++-- .../purview/tests/purview/test_processor.py | 236 ++++++++++++++++-- 7 files changed, 781 insertions(+), 53 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md index 39915507b25..8c21583e008 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -273,3 +273,39 @@ catch (PurviewException e) this._logger.LogError(e, "Purview middleware threw an exception.") } ``` + +## Security Considerations + +### Identity is a trusted input + +Purview evaluates DLP policy **for a specific user**. The identity this integration resolves therefore +decides *which* policy is applied, and it is resolved in this order: + +1. The user id from the configured `TokenCredential`'s token, when the credential resolves to a user. +2. The `userId` argument passed to the processor. +3. `ChatMessage.AdditionalProperties["user_id"]`. +4. `ChatMessage.AuthorName`, when it is a GUID. + +Only source 1 is verified. Sources 2-4 are supplied by the hosting application, so **a host must not +populate them from data that has crossed a trust boundary**. If an end user, an upstream service or a +model response can influence `AdditionalProperties["user_id"]` or `AuthorName`, that party can select a +different user's DLP policy - typically one with weaker rules - and evade enforcement. Where identity +must come from a request, derive it from a validated token on the server, never from the request body. +Prefer a user-delegated credential (source 1) whenever possible. + +`PurviewAppLocation` in `PurviewSettings` is trusted in the same way: it selects which policy locations +apply and must be configured by the host, not by the caller. + +### Fail-closed behaviour + +Policy evaluation fails closed. If no user id can be resolved, or the tenant or app location cannot be +determined, `PurviewRequestException` is thrown rather than letting content through unevaluated. Use +`IgnoreExceptions` if you deliberately want availability over enforcement - but understand that it +disables enforcement for every error, not just transient ones. + +### What is evaluated + +Every content item on a message is submitted for evaluation, not just its text: `DataContent` is sent +as Purview binary content, and `FunctionCallContent`, `FunctionResultContent` and other structured +content are serialized to text. Only `UsageContent` is skipped, because it carries token counts rather +than user data. diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index f49f1239a89..fcc0f99f377 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Purview.Models.Common; @@ -52,12 +53,7 @@ public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cache foreach (DlpActionInfo policyAction in processContentResponse.PolicyActions) { // We need to process all data before blocking, so set the flag and return it outside of this loop. - if (policyAction.Action == DlpAction.BlockAccess) - { - shouldBlock = true; - } - - if (policyAction.RestrictionAction == RestrictionAction.Block) + if (IsBlockingAction(policyAction)) { shouldBlock = true; } @@ -68,6 +64,35 @@ public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cache return (shouldBlock, resolvedUserId); } + /// + /// Whether a policy action means the content must not be released. + /// + /// The policy action to inspect. + /// when the action blocks the content. + private static bool IsBlockingAction(DlpActionInfo actionInfo) + => actionInfo.Action is DlpAction.BlockAccess or DlpAction.RestrictAccess + || actionInfo.RestrictionAction == RestrictionAction.Block; + + /// + /// Filter policy actions down to the ones that block. + /// + /// The policy actions to filter. + /// The blocking subset of . + private static List GetBlockingActions(List actionInfos) + { + List blockingActions = []; + + foreach (DlpActionInfo actionInfo in actionInfos) + { + if (IsBlockingAction(actionInfo)) + { + blockingActions.Add(actionInfo); + } + } + + return blockingActions; + } + private static bool TryGetUserIdFromPayload(IEnumerable messages, out string? userId) { userId = null; @@ -115,12 +140,21 @@ private async Task> MapMessageToPCRequestsAsync(IEnu foreach (ChatMessage message in messages) { string messageId = message.MessageId ?? Guid.NewGuid().ToString(); - ContentBase content = new PurviewTextContent(message.Text); string correlationId = (sessionId ?? Guid.NewGuid().ToString()) + "@AF"; - ProcessConversationMetadata conversationMetadata = new(content, messageId, false, $"Agent Framework Message {messageId}", correlationId) + long baseSequenceNumber = DateTime.UtcNow.Ticks; + List contentEntries = []; + int entryIndex = 0; + + foreach (ContentBase content in MapMessageContents(message)) { - SequenceNumber = DateTime.UtcNow.Ticks, - }; + string identifier = entryIndex == 0 ? messageId : $"{messageId}-{entryIndex}"; + contentEntries.Add(new ProcessConversationMetadata(content, identifier, false, $"Agent Framework Message {messageId}", correlationId) + { + SequenceNumber = baseSequenceNumber + entryIndex, + }); + entryIndex++; + } + ActivityMetadata activityMetadata = new(activity); PolicyLocation policyLocation; @@ -158,7 +192,7 @@ private async Task> MapMessageToPCRequestsAsync(IEnu OperatingSystemVersion = "Unknown" } }; - ContentToProcess contentToProcess = new([conversationMetadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); + ContentToProcess contentToProcess = new(contentEntries, activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); if (string.IsNullOrEmpty(resolvedUserId)) { @@ -172,6 +206,103 @@ private async Task> MapMessageToPCRequestsAsync(IEnu return pcRequests; } + /// + /// Map every content item of a message onto the Purview content type that fits it. + /// + /// The message whose contents should be evaluated. + /// One content item per evaluable , never empty. + /// + /// Only is skipped, because it carries no user data. Everything + /// else is mapped to a real content item: submitting a message with part of its payload + /// unevaluated is a policy bypass. + /// + private static List MapMessageContents(ChatMessage message) + { + List mapped = []; + + foreach (AIContent content in message.Contents) + { + ContentBase? purviewContent = MapContent(content); + if (purviewContent != null) + { + mapped.Add(purviewContent); + } + } + + if (mapped.Count == 0) + { + mapped.Add(new PurviewTextContent(string.Empty)); + } + + return mapped; + } + + /// + /// Map a single onto a Purview content item. + /// + /// The content item to map. + /// The Purview content item, or when the content carries no user data. + private static ContentBase? MapContent(AIContent content) + { + switch (content) + { + case UsageContent: + // Telemetry only; there is nothing for DLP to classify. + return null; + + case TextContent textContent: + return new PurviewTextContent(textContent.Text ?? string.Empty); + + case TextReasoningContent reasoningContent: + return new PurviewTextContent(reasoningContent.Text ?? string.Empty); + + case DataContent dataContent: + return new PurviewBinaryContent(dataContent.Data.ToArray()); + + case UriContent uriContent: + return new PurviewTextContent($"{uriContent.MediaType} {uriContent.Uri}"); + + case FunctionCallContent functionCallContent: + return new PurviewTextContent(SerializeForEvaluation(functionCallContent)); + + case FunctionResultContent functionResultContent: + return new PurviewTextContent(SerializeForEvaluation(functionResultContent)); + + case ErrorContent errorContent: + return new PurviewTextContent(errorContent.Message ?? string.Empty); + + default: + // Catch-all for every remaining content type, including ones added after this code + // was written. Serializing is always safe; skipping would be a policy bypass. + return new PurviewTextContent(SerializeForEvaluation(content)); + } + } + + /// + /// Render a content item as text so Purview can classify everything it carries. + /// + /// The content item to render. + /// The text representation of . + /// The content could not be rendered for evaluation. + /// + /// The type info is resolved from the concrete runtime type rather than . + /// Polymorphic serialization through the base type throws for any subclass the abstractions do not + /// declare, which would push third-party and future content types onto the failure path. + /// + private static string SerializeForEvaluation(AIContent content) + { + try + { + return JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions.GetTypeInfo(content.GetType())); + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or JsonException) + { + // Fail closed. Falling back to ToString() would submit a bare type name in place of the + // payload, so the content would clear policy without ever having been evaluated. + throw new PurviewRequestException($"Unable to serialize content of type '{content.GetType()}' for Purview policy evaluation. Content cannot be evaluated and will not be released.", ex); + } + } + /// /// Orchestrates process content and protection scopes calls. /// @@ -228,12 +359,18 @@ private async Task ProcessWithCachedScopesAsync( if (shouldProcess) { - pcRequest.ProcessInline = executionMode == ExecutionMode.EvaluateInline; + pcRequest.ProcessInline = executionMode != ExecutionMode.EvaluateOffline; if (executionMode == ExecutionMode.EvaluateOffline) { this._channelHandler.QueueJob(new ProcessContentJob(pcRequest)); - return new ProcessContentResponse(); + + // Offline evaluation is asynchronous, but an explicit block action already known + // from the cached scopes must still be enforced rather than discarded. + List blockingActions = GetBlockingActions(dlpActions); + return blockingActions.Count > 0 + ? new ProcessContentResponse { PolicyActions = blockingActions } + : new ProcessContentResponse(); } return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions, cancellationToken).ConfigureAwait(false); @@ -338,7 +475,9 @@ internal static (bool shouldProcess, List dlpActions, ExecutionMo { shouldProcess = true; - if (scope.ExecutionMode == ExecutionMode.EvaluateInline) + // Only an explicitly offline scope may skip inline evaluation. ExecutionMode is an + // evolvable enum, so any unrecognised value is upgraded to inline (fail closed). + if (scope.ExecutionMode != ExecutionMode.EvaluateOffline) { executionMode = ExecutionMode.EvaluateInline; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index b59cc7a3929..962ace26c8f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -993,10 +993,204 @@ public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSub It.IsAny()), Times.Once); } + /// + /// Verifies every content item is submitted for evaluation, not just message.Text. + /// Images, binary payloads and structured tool results are empty when flattened to text, which + /// would have them reach the model having only ever been classified as an empty string. + /// + [Fact] + public async Task ProcessMessagesAsync_WithNonTextContent_SubmitsItForEvaluationAsync() + { + // Arrange + byte[] secret = [0x01, 0x02, 0x03, 0x04]; + var messages = new List + { + new(ChatRole.User, + [ + new DataContent(secret, "application/octet-stream"), + new FunctionCallContent("call-1", "exfiltrate", new Dictionary { ["ssn"] = "123-45-6789" }) + ]) + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateApplicableProtectionScopesResponse()); + + ProcessContentRequest? capturedRequest = null; + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .Callback((request, _) => capturedRequest = request) + .ReturnsAsync(new ProcessContentResponse()); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.NotNull(capturedRequest); + List entries = capturedRequest.ContentToProcess.ContentEntries; + Assert.Equal(2, entries.Count); + + PurviewBinaryContent binaryContent = Assert.IsType(entries[0].Content); + Assert.Equal(secret, binaryContent.Data); + + PurviewTextContent functionCallContent = Assert.IsType(entries[1].Content); + Assert.Contains("123-45-6789", functionCallContent.Data, StringComparison.Ordinal); + + // Content entries must be individually addressable, not collapsed onto one identifier. + Assert.NotEqual(entries[0].Identifier, entries[1].Identifier); + } + + /// + /// Verifies a block verdict known from an offline cached scope is still enforced. The offline + /// branch reports asynchronously, but discarding the scope's own policy actions would let a + /// known restrictAccess verdict go unenforced. + /// + [Fact] + public async Task ProcessMessagesAsync_WithOfflineScopeCarryingBlockAction_ReturnsShouldBlockTrueAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + var psResponse = new ProtectionScopesResponse + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("microsoft.graph.policyLocationApplication", "app-123") + ], + ExecutionMode = ExecutionMode.EvaluateOffline, + PolicyActions = + [ + new() { Action = DlpAction.RestrictAccess, RestrictionAction = RestrictionAction.Block } + ] + } + ] + }; + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.True(result.shouldBlock); + + // The offline report is still queued; enforcement is additional, not a replacement. + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + } + + /// + /// Verifies an unrecognised is evaluated inline. The enum is + /// evolvable, so an unknown member must not be treated as permission to skip enforcement. + /// + [Fact] + public async Task ProcessMessagesAsync_WithUnknownExecutionMode_EvaluatesInlineAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateApplicableProtectionScopesResponse((ExecutionMode)9999)); + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessContentResponse + { + PolicyActions = [new() { Action = DlpAction.BlockAccess }] + }); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.True(result.shouldBlock); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Once); + } + + /// + /// Verifies an subclass the abstractions do not declare still has its + /// payload evaluated. Serializing such a type through the polymorphic base type throws, and + /// substituting its type name would submit no payload at all for classification. + /// + [Fact] + public async Task ProcessMessagesAsync_WithUnknownContentSubclass_SubmitsPayloadForEvaluationAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, [new UnknownTestContent { Secret = "ssn 123-45-6789" }]) + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateApplicableProtectionScopesResponse()); + + ProcessContentRequest? capturedRequest = null; + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .Callback((request, _) => capturedRequest = request) + .ReturnsAsync(new ProcessContentResponse()); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.NotNull(capturedRequest); + ProcessContentMetadataBase entry = Assert.Single(capturedRequest.ContentToProcess.ContentEntries); + PurviewTextContent content = Assert.IsType(entry.Content); + + Assert.Contains("123-45-6789", content.Data, StringComparison.Ordinal); + Assert.NotEqual(nameof(UnknownTestContent), content.Data); + } + #endregion #region Helper Methods + /// + /// Stands in for a third-party or future subclass that the abstractions do + /// not declare a polymorphic discriminator for. + /// + private sealed class UnknownTestContent : AIContent + { + public string Secret { get; set; } = string.Empty; + } + private static ProtectionScopesRequest CreateProtectionScopesRequest() { return new ProtectionScopesRequest("user-123", "tenant-123") diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index 40a53b839a1..3b596a75c13 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -314,8 +314,46 @@ except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError, --- +## Security Considerations + +### Identity is a trusted input + +Purview evaluates DLP policy **for a specific user**. The identity this integration resolves therefore +decides *which* policy is applied, and it is resolved in this order: + +1. The `user_id` from the configured credential's token, when the credential resolves to a user. +2. The `user_id` argument passed to the processor. +3. `message.additional_properties["user_id"]`. +4. `message.author_name`, when it is a GUID. + +Only source 1 is verified. Sources 2–4 are supplied by the hosting application, so **a host must not +populate them from data that has crossed a trust boundary**. If an end user, an upstream service or a +model response can influence `additional_properties["user_id"]` or `author_name`, that party can +select a different user's DLP policy — typically one with weaker rules — and evade enforcement. Where +identity must come from a request, derive it from a validated token on the server, never from the +request body. Prefer a user-delegated credential (source 1) whenever possible. + +The `purview_app_location` in `PurviewSettings` is trusted in the same way: it selects which policy +locations apply and must be configured by the host, not by the caller. + +### Fail-closed behaviour + +Policy evaluation fails closed. If no user id can be resolved, or the tenant or app location cannot be +determined, the processor raises rather than letting content through unevaluated. Use +`ignore_exceptions` if you deliberately want availability over enforcement — but understand that it +disables enforcement for every error, not just transient ones. + +### What is evaluated + +Every content item on a message is submitted for evaluation, not just its text: binary/data content is +sent as Purview binary content, and function calls, function results and other structured content are +serialized to text. Only `usage` content is skipped, because it carries token counts rather than user +data. + +--- + ## Notes -- **User Identification**: When the configured credential resolves to a user token, that token's `user_id` is used for per-user policy scoping. For app-token credentials, provide a `user_id` per request (e.g. in `Message(..., additional_properties={"user_id": ""})`). If no user_id is provided or inferred, policy evaluation is skipped. +- **User Identification**: When the configured credential resolves to a user token, that token's `user_id` is used for per-user policy scoping. For app-token credentials, provide a `user_id` per request (e.g. in `Message(..., additional_properties={"user_id": ""})`). If no user_id can be provided or inferred, the request fails rather than proceeding unevaluated — see [Security Considerations](#security-considerations). - **Blocking Messages**: Can be customized via `blocked_prompt_message` and `blocked_response_message` in `PurviewSettings`. By default, they are "Prompt blocked by policy" and "Response blocked by policy" respectively. - **Streaming Responses**: Post-response policy evaluation presently applies only to non-streaming chat responses. - **Error Handling**: Use `ignore_exceptions` and `ignore_payment_required` settings for graceful degradation. When enabled, errors are logged but don't fail the request. diff --git a/python/packages/purview/agent_framework_purview/_models.py b/python/packages/purview/agent_framework_purview/_models.py index 40df9d78cfe..383fc433f91 100644 --- a/python/packages/purview/agent_framework_purview/_models.py +++ b/python/packages/purview/agent_framework_purview/_models.py @@ -153,6 +153,7 @@ def serialize_flag( class DlpAction(str, Enum): BLOCK_ACCESS = "blockAccess" + RESTRICT_ACCESS = "restrictAccess" OTHER = "other" diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index d6c24a078f2..db7c71cf647 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -1,13 +1,18 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import base64 +import binascii +import json import logging +import re import time import uuid from collections.abc import Iterable, MutableMapping +from copy import copy from typing import Any -from agent_framework import Message +from agent_framework import Content, Message from ._cache import CacheProvider, InMemoryCacheProvider, create_protection_scopes_cache_key from ._client import PurviewClient @@ -16,6 +21,7 @@ Activity, ActivityMetadata, ContentActivitiesRequest, + ContentBase, ContentToProcess, DeviceMetadata, DlpAction, @@ -31,6 +37,7 @@ ProtectionScopesRequest, ProtectionScopesResponse, ProtectionScopeState, + PurviewBinaryContent, PurviewTextContent, RestrictionAction, translate_activity, @@ -51,6 +58,90 @@ def _is_valid_guid(value: str | None) -> bool: return False +_DATA_URI_PATTERN = re.compile(r"^data:(?P[^;,]+);base64,(?P.*)$", re.DOTALL) + +# Content types that carry no user data and therefore have nothing for DLP to classify. +# Every other content type must reach Purview; see _map_content. +_NON_EVALUATED_CONTENT_TYPES = frozenset({"usage"}) + + +def _serialize_for_evaluation(value: Any) -> str: + """Render an arbitrary content value as text so Purview can classify it.""" + if value is None: + return "" + if isinstance(value, str): + return value + try: + return json.dumps(value, default=str, sort_keys=True) + except (TypeError, ValueError): + return str(value) + + +def _decode_data_uri(uri: str | None) -> bytes | None: + """Return the raw bytes behind a base64 data URI, or None if it is not one.""" + if not uri: + return None + match = _DATA_URI_PATTERN.match(uri) + if not match: + return None + try: + return base64.b64decode(match.group("base64_data"), validate=True) + except (binascii.Error, ValueError): + return None + + +def _map_content(content: Content) -> ContentBase | None: + """Map a single message content item onto the Purview content type that fits it. + + Returns None only for content that carries no user data at all. Anything else is + mapped to a real content entry: a message must never reach the model with part of + its payload unevaluated. + """ + content_type = content.type + + if content_type in _NON_EVALUATED_CONTENT_TYPES: + return None + + if content_type in ("text", "text_reasoning"): + return PurviewTextContent(data=content.text or "") + + if content_type == "data": + raw_data = _decode_data_uri(getattr(content, "uri", None)) + if raw_data is not None: + return PurviewBinaryContent(data=raw_data) + # Not a base64 data URI after all: evaluate the serialized form rather than drop it. + return PurviewTextContent(data=_serialize_for_evaluation(content.to_dict())) + + # Everything else - uri, function_call, function_result and any content type added after this + # code was written - is serialized whole. Serializing the entire item rather than picking out + # named fields keeps additional_properties, which is a data channel in its own right, under + # evaluation. Skipping a content type would be a policy bypass. + return PurviewTextContent(data=_serialize_for_evaluation(content.to_dict())) + + +def _map_message_contents(message: Message) -> list[ContentBase]: + """Map every content item of a message to Purview content entries. + + Always returns at least one entry so that a message can never pass through + without being submitted for evaluation. + """ + mapped = [purview_content for content in message.contents if (purview_content := _map_content(content))] + return mapped or [PurviewTextContent(data="")] + + +def _is_blocking_action(action_info: DlpActionInfo) -> bool: + """Whether a policy action means the content must not be released.""" + return ( + action_info.action in (DlpAction.BLOCK_ACCESS, DlpAction.RESTRICT_ACCESS) + or action_info.restriction_action == RestrictionAction.BLOCK + ) + + +def _blocking_actions(dlp_actions: list[DlpActionInfo]) -> list[DlpActionInfo | MutableMapping[str, Any]]: + """Filter policy actions down to the ones that block.""" + return [action_info for action_info in dlp_actions if _is_blocking_action(action_info)] + + class ScopedContentProcessor: """Combine protection scopes, process content, and content activities logic.""" @@ -91,7 +182,7 @@ async def process_messages( resp = await self._process_with_scopes(req) if resp.policy_actions: for act in resp.policy_actions: - if act.action == DlpAction.BLOCK_ACCESS or act.restriction_action == RestrictionAction.BLOCK: + if _is_blocking_action(act): should_block = True break if should_block: @@ -141,23 +232,31 @@ async def _map_messages( if not resolved_user_id and resolved_author_name: resolved_user_id = resolved_author_name - # Return empty results if user_id is empty + # Fail closed: without a resolvable user identity no policy can be evaluated, + # so the content must not be allowed through unevaluated. if not resolved_user_id or not _is_valid_guid(resolved_user_id): - return results, None + raise ValueError( + "No user id provided or inferred for Purview request. Please provide an Entra user id in each " + "message, pass a user id to the processor, or configure the credential to authenticate to an " + "Entra user." + ) for m in messages: message_id = m.message_id or str(uuid.uuid4()) - content = PurviewTextContent(data=m.text or "") correlation_id = (session_id or str(uuid.uuid4())) + "@AF" - meta = ProcessConversationMetadata( - identifier=message_id, - content=content, - name=f"Agent Framework Message {message_id}", - is_truncated=False, - correlation_id=correlation_id, - # This would be c# ticks equivalent and needs to fit inside c# long - sequence_number=time.time_ns() // 100 + 621355968000000000, - ) + # This would be c# ticks equivalent and needs to fit inside c# long + base_sequence_number = time.time_ns() // 100 + 621355968000000000 + content_entries: list[ProcessConversationMetadata | MutableMapping[str, Any]] = [ + ProcessConversationMetadata( + identifier=message_id if index == 0 else f"{message_id}-{index}", + content=purview_content, + name=f"Agent Framework Message {message_id}", + is_truncated=False, + correlation_id=correlation_id, + sequence_number=base_sequence_number + index, + ) + for index, purview_content in enumerate(_map_message_contents(m)) + ] activity_meta = ActivityMetadata(activity=activity) purview_app_location = self._settings.get("purview_app_location") @@ -188,7 +287,7 @@ async def _map_messages( ) ctp = ContentToProcess( - content_entries=[meta], + content_entries=content_entries, activity_metadata=activity_meta, device_metadata=device_meta, integrated_app_metadata=integrated_app, @@ -198,7 +297,7 @@ async def _map_messages( content_to_process=ctp, user_id=resolved_user_id, # Use the resolved user_id for all messages tenant_id=tenant_id, - correlation_id=meta.correlation_id, + correlation_id=correlation_id, process_inline=None, # Will be set based on execution mode ) results.append(req) @@ -231,7 +330,9 @@ async def _process_with_scopes(self, pc_request: ProcessContentRequest) -> Proce return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key) pc_request.process_inline = True - task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request)) + # The background refresh gets its own copy: the foreground call mutates + # process_inline and scope_identifier on this request while that task runs. + task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, copy(pc_request))) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) return await self._call_process_content(pc_request, cache_key, dlp_actions=[]) @@ -248,15 +349,23 @@ async def _process_with_cached_scopes( should_process, dlp_actions, execution_mode = self._check_applicable_scopes(pc_request, ps_resp) if should_process: - # Set process_inline based on execution mode - pc_request.process_inline = execution_mode == ExecutionMode.EVALUATE_INLINE + # Only an explicitly offline scope may skip inline evaluation. executionMode is an + # evolvable enum, so any unrecognised value must fail closed and be evaluated inline. + evaluate_offline = execution_mode == ExecutionMode.EVALUATE_OFFLINE + pc_request.process_inline = not evaluate_offline # If execution mode is offline, queue the PC request in background - if execution_mode != ExecutionMode.EVALUATE_INLINE: + if evaluate_offline: task = asyncio.create_task(self._process_content_background(pc_request, cache_key)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) - return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id) + # Offline evaluation is asynchronous, but an explicit block action already known + # from the cached scopes must still be enforced rather than discarded. + return ProcessContentResponse( + id="204", + correlation_id=pc_request.correlation_id, + policy_actions=_blocking_actions(dlp_actions) or None, + ) return await self._call_process_content(pc_request, cache_key, dlp_actions=dlp_actions) @@ -373,15 +482,18 @@ def _check_applicable_scopes( loc.data_type and location.data_type and loc.data_type.lower().endswith(location.data_type.split(".")[-1].lower()) - and loc.value == location.value + and isinstance(loc.value, str) + and isinstance(location.value, str) + and loc.value.casefold() == location.value.casefold() ): location_match = True break if activity_match and location_match: should_process = True - # If any scope has EvaluateInline, upgrade to inline mode - if scope.execution_mode == ExecutionMode.EVALUATE_INLINE: + # Only an explicitly offline scope may skip inline evaluation. execution_mode is an + # evolvable enum, so any unrecognised value is upgraded to inline (fail closed). + if scope.execution_mode != ExecutionMode.EVALUATE_OFFLINE: execution_mode = ExecutionMode.EVALUATE_INLINE if scope.policy_actions: diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 9d7388a416b..31b71f9d30c 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -20,6 +20,8 @@ ProcessContentResponse, ProtectionScopeActivities, ProtectionScopeState, + PurviewBinaryContent, + PurviewTextContent, RestrictionAction, ) from agent_framework_purview._processor import ScopedContentProcessor, _is_valid_guid @@ -144,6 +146,80 @@ async def test_map_messages_creates_requests( assert requests[0].tenant_id == "12345678-1234-1234-1234-123456789012" assert user_id == "12345678-1234-1234-1234-123456789012" + async def test_map_messages_submits_every_content_item(self, processor: ScopedContentProcessor) -> None: + """Test _map_messages submits every content item, not just the text. + + Non-text content flattened to Message.text is empty for binary, tool-call and + tool-result content, which would have Purview classify an empty string. + """ + from agent_framework import Content + + secret = b"credit card 4532667785213500" + messages = [ + Message( + role="user", + contents=[ + Content.from_data(data=secret, media_type="application/octet-stream"), + Content( + "function_call", + call_id="call-1", + name="send_email", + arguments={"body": "ssn 120-98-1437"}, + ), + Content("function_result", call_id="call-1", result="account 999-12345"), + ], + ) + ] + + requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT) + + assert len(requests) == 1 + entries = requests[0].content_to_process.content_entries + assert len(entries) == 3 + + binary_entry, call_entry, result_entry = entries + assert isinstance(binary_entry.content, PurviewBinaryContent) + assert binary_entry.content.data == secret + + assert isinstance(call_entry.content, PurviewTextContent) + assert "send_email" in call_entry.content.data + assert "120-98-1437" in call_entry.content.data + + assert isinstance(result_entry.content, PurviewTextContent) + assert "999-12345" in result_entry.content.data + + # Every entry must carry real content; an empty payload would not be evaluated. + assert all(entry.content is not None for entry in entries) + assert not any(isinstance(entry.content, PurviewTextContent) and entry.content.data == "" for entry in entries) + + async def test_map_messages_submits_additional_properties_on_structured_content( + self, processor: ScopedContentProcessor + ) -> None: + """Test _map_messages serializes structured content whole, including additional_properties. + + Building an entry from name/arguments alone drops additional_properties, leaving a + data channel on tool calls and tool results that Purview never sees. + """ + from agent_framework import Content + + call = Content("function_call", call_id="call-1", name="send_email", arguments={"body": "hello"}) + call.additional_properties = {"hidden": "ssn 120-98-1437"} + result = Content("function_result", call_id="call-1", result="ok") + result.additional_properties = {"hidden": "card 4532667785213500"} + + messages = [Message(role="user", contents=[call, result])] + + requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT) + + call_entry, result_entry = requests[0].content_to_process.content_entries + + assert isinstance(call_entry.content, PurviewTextContent) + assert "120-98-1437" in call_entry.content.data + assert "send_email" in call_entry.content.data + + assert isinstance(result_entry.content, PurviewTextContent) + assert "4532667785213500" in result_entry.content.data + async def test_map_messages_without_defaults_gets_token_info(self, mock_client: AsyncMock) -> None: """Test _map_messages gets token info when settings lack some defaults.""" settings = PurviewSettings(app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012") @@ -220,6 +296,42 @@ async def test_check_applicable_scopes_with_block_action( assert len(actions) == 1 assert actions[0].action == DlpAction.BLOCK_ACCESS + async def test_check_applicable_scopes_matches_location_case_insensitively( + self, process_content_request_factory + ) -> None: + """Test _check_applicable_scopes matches location values regardless of GUID casing. + + A casing difference between the request location and the scope location would + otherwise hide an applicable block scope. + """ + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + pc_request.content_to_process.protected_app_metadata.application_location = PolicyLocation( + data_type="microsoft.graph.policyLocationApplication", + value="A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D", + ) + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.EVALUATE_INLINE, + locations=[ + PolicyLocation( + data_type="#microsoft.graph.policyLocationApplication", + value="a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", + ) + ], + policy_actions=[DlpActionInfo(action=DlpAction.BLOCK_ACCESS)], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + + should_process, dlp_actions, execution_mode = ScopedContentProcessor._check_applicable_scopes( + pc_request, ps_response + ) + + assert should_process is True + assert execution_mode == ExecutionMode.EVALUATE_INLINE + assert dlp_actions + async def test_combine_policy_actions(self, processor: ScopedContentProcessor) -> None: """Test _combine_policy_actions merges action lists.""" action1 = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restrictionAction=RestrictionAction.BLOCK) @@ -290,6 +402,87 @@ async def test_process_with_scopes_calls_client_methods( mock_client.get_protection_scopes.assert_called_once() mock_client.send_content_activities.assert_called_once() + async def test_process_with_scopes_isolates_the_background_refresh_request( + self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """Test a cold scopes cache evaluates inline and hands the background refresh its own request. + + The background refresh must not observe foreground mutations of the request it was given. + """ + pc_request = process_content_request_factory() + cast(Any, processor._cache).get = AsyncMock(return_value=None) + mock_client.process_content.return_value = ProcessContentResponse(id="1") + + captured: list[Any] = [] + + async def capture_refresh(ps_req: Any, cache_key: str, request: Any) -> None: + captured.append(request) + + cast(Any, processor)._refresh_protection_scopes_background = capture_refresh + + await processor._process_with_scopes(pc_request) + await asyncio.gather(*list(processor._background_tasks)) + + # Content was evaluated inline, so there is no window in which it goes unevaluated. + mock_client.process_content.assert_called_once() + assert pc_request.process_inline is True + + # The background task got its own request object. + assert captured and captured[0] is not pc_request + + async def test_process_with_scopes_evaluates_unknown_execution_mode_inline( + self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """Test an unrecognised executionMode is evaluated inline. + + executionMode is an evolvable enum, so an unknown member must not skip enforcement. + """ + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.UNKNOWN_FUTURE_VALUE, + locations=[PolicyLocation(data_type="microsoft.graph.policyLocationApplication", value="app-id")], + policy_actions=[], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + cast(Any, processor._cache).get = AsyncMock(side_effect=[None, ps_response]) + mock_client.process_content.return_value = ProcessContentResponse(id="1") + + await processor._process_with_scopes(pc_request) + + mock_client.process_content.assert_called_once() + assert pc_request.process_inline is True + + async def test_process_with_scopes_reports_block_action_on_offline_scope( + self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """Test a block verdict known from an offline scope is still reported rather than discarded.""" + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.EVALUATE_OFFLINE, + locations=[PolicyLocation(data_type="microsoft.graph.policyLocationApplication", value="app-id")], + policy_actions=[ + DlpActionInfo(action=DlpAction.RESTRICT_ACCESS, restriction_action=RestrictionAction.BLOCK) + ], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + cast(Any, processor._cache).get = AsyncMock(side_effect=[None, ps_response]) + mock_client.process_content.return_value = ProcessContentResponse(id="1") + + response = await processor._process_with_scopes(pc_request) + await asyncio.gather(*list(processor._background_tasks)) + + assert response.policy_actions + assert any( + action.action == DlpAction.RESTRICT_ACCESS or action.restriction_action == RestrictionAction.BLOCK + for action in response.policy_actions + ) + async def test_process_with_scopes_preserves_restriction_only_policy_actions( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory ) -> None: @@ -516,8 +709,8 @@ async def test_map_messages_with_provided_user_id_fallback(self, mock_client: As assert user_id == "32345678-1234-1234-1234-123456789012" assert requests[0].user_id == "32345678-1234-1234-1234-123456789012" - async def test_map_messages_returns_empty_when_no_user_id(self, mock_client: AsyncMock) -> None: - """Test that empty results are returned when user_id cannot be resolved.""" + async def test_map_messages_raises_when_no_user_id(self, mock_client: AsyncMock) -> None: + """Test that an unresolvable user_id fails closed instead of skipping evaluation.""" settings = PurviewSettings( app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012", @@ -533,10 +726,8 @@ async def test_map_messages_returns_empty_when_no_user_id(self, mock_client: Asy messages = [Message(role="user", contents=["Test message"])] - requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) - - assert len(requests) == 0 - assert user_id is None + with pytest.raises(ValueError, match="No user id"): + await processor._map_messages(messages, Activity.UPLOAD_TEXT) async def test_process_content_sends_activities_when_not_applicable( self, mock_client: AsyncMock, process_content_request_factory @@ -705,11 +896,9 @@ async def test_author_name_ignored_if_not_valid_guid( ) ] - requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) - - # Should return empty since author_name is not a valid GUID - assert user_id is None - assert len(requests) == 0 + # author_name is not a valid GUID, so no identity resolves and evaluation fails closed + with pytest.raises(ValueError, match="No user id"): + await processor._map_messages(messages, Activity.UPLOAD_TEXT) async def test_provided_user_id_used_as_last_resort( self, mock_client: AsyncMock, settings: PurviewSettings @@ -739,10 +928,29 @@ async def test_invalid_provided_user_id_ignored(self, mock_client: AsyncMock, se messages = [Message(role="user", contents=["Test"])] - requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT, provided_user_id="not-a-guid") + with pytest.raises(ValueError, match="No user id"): + await processor._map_messages(messages, Activity.UPLOAD_TEXT, provided_user_id="not-a-guid") - assert user_id is None - assert len(requests) == 0 + async def test_unresolvable_user_id_blocks_processing( + self, mock_client: AsyncMock, settings: PurviewSettings + ) -> None: + """Test process_messages raises rather than sending content when no user id resolves. + + Without a user id there is no policy to evaluate against, so the content must not be + forwarded to Purview or on to the model. + """ + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } + processor = ScopedContentProcessor(mock_client, settings) + + messages = [Message(role="user", contents=["ssn 120-98-1437"])] + + with pytest.raises(ValueError, match="No user id"): + await processor.process_messages(messages, Activity.UPLOAD_TEXT) + + mock_client.process_content.assert_not_called() async def test_multiple_messages_same_user_id(self, mock_client: AsyncMock, settings: PurviewSettings) -> None: """Test that all messages use the same resolved user_id.""" From f27d29b4e9d4295222a1c9c508701198c2d2ef74 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:20:14 +0000 Subject: [PATCH 2/5] Address PR comments --- .../src/Microsoft.Agents.AI.Purview/README.md | 4 +- .../ScopedContentProcessor.cs | 41 ++++- .../ScopedContentProcessorTests.cs | 140 ++++++++++++++++++ .../agent_framework_purview/_processor.py | 41 ++++- .../purview/tests/purview/test_processor.py | 123 ++++++++++++++- 5 files changed, 336 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md index 8c21583e008..e92d1380e13 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -283,12 +283,12 @@ decides *which* policy is applied, and it is resolved in this order: 1. The user id from the configured `TokenCredential`'s token, when the credential resolves to a user. 2. The `userId` argument passed to the processor. -3. `ChatMessage.AdditionalProperties["user_id"]`. +3. `ChatMessage.AdditionalProperties["userId"]`. 4. `ChatMessage.AuthorName`, when it is a GUID. Only source 1 is verified. Sources 2-4 are supplied by the hosting application, so **a host must not populate them from data that has crossed a trust boundary**. If an end user, an upstream service or a -model response can influence `AdditionalProperties["user_id"]` or `AuthorName`, that party can select a +model response can influence `AdditionalProperties["userId"]` or `AuthorName`, that party can select a different user's DLP policy - typically one with weaker rules - and evade enforcement. Where identity must come from a request, derive it from a validated token on the server, never from the request body. Prefer a user-delegated credential (source 1) whenever possible. diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index fcc0f99f377..5d59c2af81c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -67,10 +67,17 @@ public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cache /// /// Whether a policy action means the content must not be released. /// + /// + /// is not blocking on its own: it carries a separate + /// that selects the enforcement mode, which may be + /// , or + /// as well as . Only an + /// explicit mode withholds the content. + /// /// The policy action to inspect. /// when the action blocks the content. private static bool IsBlockingAction(DlpActionInfo actionInfo) - => actionInfo.Action is DlpAction.BlockAccess or DlpAction.RestrictAccess + => actionInfo.Action == DlpAction.BlockAccess || actionInfo.RestrictionAction == RestrictionAction.Block; /// @@ -93,6 +100,35 @@ private static List GetBlockingActions(List action return blockingActions; } + /// + /// Normalize a policy location value for comparison, according to its location type. + /// + /// + /// Application ids (GUIDs) and domain names are case-insensitive, so those fold whole. URL values + /// are not: the scheme and host are case-insensitive but the path and query are case-sensitive, so + /// folding a URL whole would let a scope for contoso.com/public match a request for + /// contoso.com/Public. Location types that are not recognized fold whole, which matches more + /// scopes rather than fewer. + /// + /// The location type segment, for example policyLocationUrl. + /// The location value to normalize. + /// The value in its comparable form. + private static string NormalizeLocationValue(string locationType, string value) + { + if (!locationType.EndsWith("url", StringComparison.OrdinalIgnoreCase)) + { + return value.ToUpperInvariant(); + } + + int schemeEnd = value.IndexOf("://", StringComparison.Ordinal); + int hostStart = schemeEnd >= 0 ? schemeEnd + 3 : 0; + int pathStart = value.IndexOf('/', hostStart); + + return pathStart < 0 + ? value.ToUpperInvariant() + : string.Concat(value.Substring(0, pathStart).ToUpperInvariant(), value.Substring(pathStart)); + } + private static bool TryGetUserIdFromPayload(IEnumerable messages, out string? userId) { userId = null; @@ -464,7 +500,8 @@ internal static (bool shouldProcess, List dlpActions, ExecutionMo foreach (var location in scope.Locations ?? Array.Empty()) { - if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase)) + if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) + && NormalizeLocationValue(locationType, location.Value).Equals(NormalizeLocationValue(locationType, locationValue), StringComparison.Ordinal)) { locationMatch = true; break; diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index 962ace26c8f..dadab61e541 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -296,6 +296,91 @@ public void CheckApplicableScopes_MatchesAnyLocationInScope() Assert.Equal(ExecutionMode.EvaluateInline, executionMode); } + [Fact] + public void CheckApplicableScopes_MatchesUrlLocationHostCaseInsensitively() + { + // Arrange + ProcessContentRequest pcRequest = CreateProcessContentRequest(); + pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation = + new("microsoft.graph.policyLocationUrl", "HTTPS://Contoso.com/sites/marketing"); + ProtectionScopesResponse psResponse = new() + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = [new("#microsoft.graph.policyLocationUrl", "https://contoso.com/sites/marketing")], + ExecutionMode = ExecutionMode.EvaluateInline + } + ] + }; + + // Act + (bool shouldProcess, _, _) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse); + + // Assert + Assert.True(shouldProcess); + } + + [Fact] + public void CheckApplicableScopes_TreatsUrlLocationPathAsCaseSensitive() + { + // Arrange + ProcessContentRequest pcRequest = CreateProcessContentRequest(); + pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation = + new("microsoft.graph.policyLocationUrl", "https://contoso.com/sites/Marketing"); + ProtectionScopesResponse psResponse = new() + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = [new("#microsoft.graph.policyLocationUrl", "https://contoso.com/sites/marketing")], + ExecutionMode = ExecutionMode.EvaluateInline + } + ] + }; + + // Act + (bool shouldProcess, List dlpActions, _) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse); + + // Assert + Assert.False(shouldProcess); + Assert.Empty(dlpActions); + } + + [Fact] + public void CheckApplicableScopes_MatchesApplicationLocationCaseInsensitively() + { + // Arrange + ProcessContentRequest pcRequest = CreateProcessContentRequest(); + pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation = + new("microsoft.graph.policyLocationApplication", "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D"); + ProtectionScopesResponse psResponse = new() + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("#microsoft.graph.policyLocationApplication", "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d") + ], + ExecutionMode = ExecutionMode.EvaluateInline + } + ] + }; + + // Act + (bool shouldProcess, _, _) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse); + + // Assert + Assert.True(shouldProcess); + } + [Fact] public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync() { @@ -1099,6 +1184,61 @@ public async Task ProcessMessagesAsync_WithOfflineScopeCarryingBlockAction_Retur this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); } + /// + /// Verifies a restrictAccess scope whose restriction mode does not block is not enforced as + /// a block. The action carries a separate that may be + /// , or + /// , none of which withhold the content. + /// + [Fact] + public async Task ProcessMessagesAsync_WithOfflineScopeCarryingAuditRestriction_ReturnsShouldBlockFalseAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + var psResponse = new ProtectionScopesResponse + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("microsoft.graph.policyLocationApplication", "app-123") + ], + ExecutionMode = ExecutionMode.EvaluateOffline, + PolicyActions = + [ + new() { Action = DlpAction.RestrictAccess, RestrictionAction = RestrictionAction.Audit } + ] + } + ] + }; + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.False(result.shouldBlock); + + // The offline report is still queued so the audit action is recorded. + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + } + /// /// Verifies an unrecognised is evaluated inline. The enum is /// evolvable, so an unknown member must not be treated as permission to skip enforcement. diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index db7c71cf647..f570ec13b96 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -58,7 +58,14 @@ def _is_valid_guid(value: str | None) -> bool: return False -_DATA_URI_PATTERN = re.compile(r"^data:(?P[^;,]+);base64,(?P.*)$", re.DOTALL) +# RFC 2397: data:[][;base64],, where may be followed by any number of +# ";parameter=value" segments (for example "data:text/plain;charset=utf-8;base64,..."). The optional +# parameters have to be matched explicitly, otherwise a parameterised media type fails to decode and +# its payload would be submitted as a base64 string that no classifier can read. +_DATA_URI_PATTERN = re.compile( + r"^data:(?P[^;,]*)(?:;[^;,]+)*?;base64,(?P.*)$", + re.DOTALL | re.IGNORECASE, +) # Content types that carry no user data and therefore have nothing for DLP to classify. # Every other content type must reach Purview; see _map_content. @@ -130,11 +137,13 @@ def _map_message_contents(message: Message) -> list[ContentBase]: def _is_blocking_action(action_info: DlpActionInfo) -> bool: - """Whether a policy action means the content must not be released.""" - return ( - action_info.action in (DlpAction.BLOCK_ACCESS, DlpAction.RESTRICT_ACCESS) - or action_info.restriction_action == RestrictionAction.BLOCK - ) + """Whether a policy action means the content must not be released. + + ``restrictAccess`` is not blocking on its own: it carries a separate ``restrictionAction`` that + selects the enforcement mode, which may be audit, warn or allow as well as block. Only an explicit + block mode withholds the content. + """ + return action_info.action == DlpAction.BLOCK_ACCESS or action_info.restriction_action == RestrictionAction.BLOCK def _blocking_actions(dlp_actions: list[DlpActionInfo]) -> list[DlpActionInfo | MutableMapping[str, Any]]: @@ -142,6 +151,23 @@ def _blocking_actions(dlp_actions: list[DlpActionInfo]) -> list[DlpActionInfo | return [action_info for action_info in dlp_actions if _is_blocking_action(action_info)] +def _normalize_location_value(data_type: str, value: str) -> str: + """Normalize a policy location value for comparison, according to its location type. + + Application ids (GUIDs) and domain names are case-insensitive, so those fold whole. URL values are + not: the scheme and host are case-insensitive but the path and query are case-sensitive, so folding + a URL whole would let a scope for ``contoso.com/public`` match a request for ``contoso.com/Public``. + Location types that are not recognised fold whole, which matches more scopes rather than fewer. + """ + if data_type.split(".")[-1].casefold().endswith("url"): + scheme, separator, remainder = value.partition("://") + if not separator: + scheme, separator, remainder = "", "", value + host, slash, path = remainder.partition("/") + return f"{scheme.casefold()}{separator}{host.casefold()}{slash}{path}" + return value.casefold() + + class ScopedContentProcessor: """Combine protection scopes, process content, and content activities logic.""" @@ -484,7 +510,8 @@ def _check_applicable_scopes( and loc.data_type.lower().endswith(location.data_type.split(".")[-1].lower()) and isinstance(loc.value, str) and isinstance(location.value, str) - and loc.value.casefold() == location.value.casefold() + and _normalize_location_value(location.data_type, loc.value) + == _normalize_location_value(location.data_type, location.value) ): location_match = True break diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 31b71f9d30c..5dbea7aaff5 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -192,6 +192,31 @@ async def test_map_messages_submits_every_content_item(self, processor: ScopedCo assert all(entry.content is not None for entry in entries) assert not any(isinstance(entry.content, PurviewTextContent) and entry.content.data == "" for entry in entries) + async def test_map_messages_decodes_data_uri_with_media_type_parameters( + self, processor: ScopedContentProcessor + ) -> None: + """Test _map_messages decodes base64 data URIs whose media type carries parameters. + + A data URI may carry any number of ";parameter=value" segments between the media type and + ";base64". Failing to decode one leaves the payload as a base64 string that no classifier + can read. + """ + from agent_framework import Content + + secret = b"credit card 4532667785213500" + content = Content.from_data(data=secret, media_type="text/plain;charset=utf-8") + assert content.uri is not None + assert content.uri.startswith("data:text/plain;charset=utf-8;base64,") + + messages = [Message(role="user", contents=[content])] + + requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT) + + entries = requests[0].content_to_process.content_entries + assert len(entries) == 1 + assert isinstance(entries[0].content, PurviewBinaryContent) + assert entries[0].content.data == secret + async def test_map_messages_submits_additional_properties_on_structured_content( self, processor: ScopedContentProcessor ) -> None: @@ -332,6 +357,72 @@ async def test_check_applicable_scopes_matches_location_case_insensitively( assert execution_mode == ExecutionMode.EVALUATE_INLINE assert dlp_actions + async def test_check_applicable_scopes_matches_url_location_host_case_insensitively( + self, process_content_request_factory + ) -> None: + """Test _check_applicable_scopes ignores host casing on URL locations. + + The scheme and host of a URL are case-insensitive, so a casing difference there must not + hide an applicable scope. + """ + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + pc_request.content_to_process.protected_app_metadata.application_location = PolicyLocation( + data_type="microsoft.graph.policyLocationUrl", + value="HTTPS://Contoso.com/sites/marketing", + ) + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.EVALUATE_INLINE, + locations=[ + PolicyLocation( + data_type="#microsoft.graph.policyLocationUrl", + value="https://contoso.com/sites/marketing", + ) + ], + policy_actions=[DlpActionInfo(action=DlpAction.BLOCK_ACCESS)], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + + should_process, dlp_actions, _ = ScopedContentProcessor._check_applicable_scopes(pc_request, ps_response) + + assert should_process is True + assert dlp_actions + + async def test_check_applicable_scopes_treats_url_location_path_as_case_sensitive( + self, process_content_request_factory + ) -> None: + """Test _check_applicable_scopes keeps URL path casing significant. + + URL paths are case-sensitive, so a scope scoped to one path must not match a different path + that differs only in casing. + """ + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + pc_request.content_to_process.protected_app_metadata.application_location = PolicyLocation( + data_type="microsoft.graph.policyLocationUrl", + value="https://contoso.com/sites/Marketing", + ) + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.EVALUATE_INLINE, + locations=[ + PolicyLocation( + data_type="#microsoft.graph.policyLocationUrl", + value="https://contoso.com/sites/marketing", + ) + ], + policy_actions=[DlpActionInfo(action=DlpAction.BLOCK_ACCESS)], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + + should_process, dlp_actions, _ = ScopedContentProcessor._check_applicable_scopes(pc_request, ps_response) + + assert should_process is False + assert dlp_actions == [] + async def test_combine_policy_actions(self, processor: ScopedContentProcessor) -> None: """Test _combine_policy_actions merges action lists.""" action1 = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restrictionAction=RestrictionAction.BLOCK) @@ -478,11 +569,39 @@ async def test_process_with_scopes_reports_block_action_on_offline_scope( await asyncio.gather(*list(processor._background_tasks)) assert response.policy_actions - assert any( - action.action == DlpAction.RESTRICT_ACCESS or action.restriction_action == RestrictionAction.BLOCK + assert all( + action.action == DlpAction.RESTRICT_ACCESS and action.restriction_action == RestrictionAction.BLOCK for action in response.policy_actions ) + async def test_process_with_scopes_ignores_non_blocking_restriction_on_offline_scope( + self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """Test a restrictAccess scope whose restriction mode does not block is not reported as blocking. + + restrictAccess carries a separate restriction mode which may be audit, warn or allow. Only an + explicit block mode withholds the content, so the other modes must not surface as a verdict. + """ + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.EVALUATE_OFFLINE, + locations=[PolicyLocation(data_type="microsoft.graph.policyLocationApplication", value="app-id")], + policy_actions=[ + DlpActionInfo(action=DlpAction.RESTRICT_ACCESS, restriction_action=RestrictionAction.OTHER) + ], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + cast(Any, processor._cache).get = AsyncMock(side_effect=[None, ps_response]) + mock_client.process_content.return_value = ProcessContentResponse(id="1") + + response = await processor._process_with_scopes(pc_request) + await asyncio.gather(*list(processor._background_tasks)) + + assert not response.policy_actions + async def test_process_with_scopes_preserves_restriction_only_policy_actions( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory ) -> None: From 252dacbce5b059d4d95b6bb6ceab68ba1f715e8c Mon Sep 17 00:00:00 2001 From: Eoin Doherty Date: Mon, 14 Sep 2026 17:17:28 -0700 Subject: [PATCH 3/5] Split Purview content processing requests Create one processContent request per content entry in both .NET and Python while preserving the Graph contentEntries array contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/Common/ContentToProcess.cs | 6 +-- .../ScopedContentProcessor.cs | 28 +++++----- .../PurviewClientTests.cs | 2 +- .../ScopedContentProcessorTests.cs | 22 ++++---- .../agent_framework_purview/_models.py | 15 +++--- .../agent_framework_purview/_processor.py | 51 +++++++++---------- .../purview/tests/purview/conftest.py | 2 +- .../purview/tests/purview/test_processor.py | 17 ++++--- .../tests/purview/test_purview_models.py | 7 +-- 9 files changed, 74 insertions(+), 76 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs index 9e2e5824f3a..38bdd10b681 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs @@ -14,19 +14,19 @@ internal sealed class ContentToProcess /// /// Creates a new instance of ContentToProcess. /// - /// The content to send and its associated ids. + /// The content to send and its associated id. /// Metadata about the activity performed with the content. /// Metadata about the device that produced the content. /// Metadata about the application integrating with Purview. /// Metadata about the application being protected by Purview. public ContentToProcess( - List contentEntries, + ProcessContentMetadataBase contentEntry, ActivityMetadata activityMetadata, DeviceMetadata deviceMetadata, IntegratedAppMetadata integratedAppMetadata, ProtectedAppMetadata protectedAppMetadata) { - this.ContentEntries = contentEntries; + this.ContentEntries = [contentEntry]; this.ActivityMetadata = activityMetadata; this.DeviceMetadata = deviceMetadata; this.IntegratedAppMetadata = integratedAppMetadata; diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index 5d59c2af81c..1572b91bcf0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -178,19 +178,7 @@ private async Task> MapMessageToPCRequestsAsync(IEnu string messageId = message.MessageId ?? Guid.NewGuid().ToString(); string correlationId = (sessionId ?? Guid.NewGuid().ToString()) + "@AF"; long baseSequenceNumber = DateTime.UtcNow.Ticks; - List contentEntries = []; - int entryIndex = 0; - - foreach (ContentBase content in MapMessageContents(message)) - { - string identifier = entryIndex == 0 ? messageId : $"{messageId}-{entryIndex}"; - contentEntries.Add(new ProcessConversationMetadata(content, identifier, false, $"Agent Framework Message {messageId}", correlationId) - { - SequenceNumber = baseSequenceNumber + entryIndex, - }); - entryIndex++; - } - + List mappedContents = MapMessageContents(message); ActivityMetadata activityMetadata = new(activity); PolicyLocation policyLocation; @@ -228,15 +216,23 @@ private async Task> MapMessageToPCRequestsAsync(IEnu OperatingSystemVersion = "Unknown" } }; - ContentToProcess contentToProcess = new(contentEntries, activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); if (string.IsNullOrEmpty(resolvedUserId)) { throw new PurviewRequestException("No user id provided or inferred for Purview request. Please provide an Entra user id in each message, pass a user id to the processor, or configure the TokenCredential to authenticate to an Entra user."); } - ProcessContentRequest pcRequest = new(contentToProcess, resolvedUserId, tenantId); - pcRequests.Add(pcRequest); + for (int entryIndex = 0; entryIndex < mappedContents.Count; entryIndex++) + { + string identifier = entryIndex == 0 ? messageId : $"{messageId}-{entryIndex}"; + ProcessConversationMetadata contentEntry = new(mappedContents[entryIndex], identifier, false, $"Agent Framework Message {messageId}", correlationId) + { + SequenceNumber = baseSequenceNumber + entryIndex, + }; + ContentToProcess contentToProcess = new(contentEntry, activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); + ProcessContentRequest pcRequest = new(contentToProcess, resolvedUserId, tenantId); + pcRequests.Add(pcRequest); + } } return pcRequests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs index c3415d42af4..fa9c19878ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -519,7 +519,7 @@ private static ContentToProcess CreateValidContentToProcess() }; return new ContentToProcess( - [metadata], + metadata, activityMetadata, deviceMetadata, integratedAppMetadata, diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index dadab61e541..5fcd36a5b46 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -1079,12 +1079,12 @@ public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSub } /// - /// Verifies every content item is submitted for evaluation, not just message.Text. + /// Verifies every content item is submitted for evaluation in a separate request, not just message.Text. /// Images, binary payloads and structured tool results are empty when flattened to text, which /// would have them reach the model having only ever been classified as an empty string. /// [Fact] - public async Task ProcessMessagesAsync_WithNonTextContent_SubmitsItForEvaluationAsync() + public async Task ProcessMessagesAsync_WithMultipleContentItems_SubmitsEachInSeparateRequestAsync() { // Arrange byte[] secret = [0x01, 0x02, 0x03, 0x04]; @@ -1105,10 +1105,10 @@ public async Task ProcessMessagesAsync_WithNonTextContent_SubmitsItForEvaluation It.IsAny(), It.IsAny())) .ReturnsAsync(CreateApplicableProtectionScopesResponse()); - ProcessContentRequest? capturedRequest = null; + List capturedRequests = []; this._mockPurviewClient.Setup(x => x.ProcessContentAsync( It.IsAny(), It.IsAny())) - .Callback((request, _) => capturedRequest = request) + .Callback((request, _) => capturedRequests.Add(request)) .ReturnsAsync(new ProcessContentResponse()); // Act @@ -1116,18 +1116,18 @@ await this._processor.ProcessMessagesAsync( messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); // Assert - Assert.NotNull(capturedRequest); - List entries = capturedRequest.ContentToProcess.ContentEntries; - Assert.Equal(2, entries.Count); + Assert.Equal(2, capturedRequests.Count); + ProcessContentMetadataBase binaryEntry = Assert.Single(capturedRequests[0].ContentToProcess.ContentEntries); + ProcessContentMetadataBase functionCallEntry = Assert.Single(capturedRequests[1].ContentToProcess.ContentEntries); - PurviewBinaryContent binaryContent = Assert.IsType(entries[0].Content); + PurviewBinaryContent binaryContent = Assert.IsType(binaryEntry.Content); Assert.Equal(secret, binaryContent.Data); - PurviewTextContent functionCallContent = Assert.IsType(entries[1].Content); + PurviewTextContent functionCallContent = Assert.IsType(functionCallEntry.Content); Assert.Contains("123-45-6789", functionCallContent.Data, StringComparison.Ordinal); // Content entries must be individually addressable, not collapsed onto one identifier. - Assert.NotEqual(entries[0].Identifier, entries[1].Identifier); + Assert.NotEqual(binaryEntry.Identifier, functionCallEntry.Identifier); } /// @@ -1388,7 +1388,7 @@ private static ProcessContentRequest CreateProcessContentRequest() Version = "1.0" }; ContentToProcess contentToProcess = new( - [metadata], + metadata, activityMetadata, deviceMetadata, integratedAppMetadata, diff --git a/python/packages/purview/agent_framework_purview/_models.py b/python/packages/purview/agent_framework_purview/_models.py index 383fc433f91..76e1b88acc0 100644 --- a/python/packages/purview/agent_framework_purview/_models.py +++ b/python/packages/purview/agent_framework_purview/_models.py @@ -612,7 +612,7 @@ class ContentToProcess(_AliasSerializable): def __init__( self, - content_entries: list[ProcessConversationMetadata | MutableMapping[str, Any]], + content_entry: ProcessConversationMetadata | MutableMapping[str, Any], activity_metadata: ActivityMetadata | MutableMapping[str, Any], device_metadata: DeviceMetadata | MutableMapping[str, Any], integrated_app_metadata: IntegratedAppMetadata | MutableMapping[str, Any], @@ -620,8 +620,6 @@ def __init__( **kwargs: Any, ) -> None: # Extract aliased values from kwargs - if "contentEntries" in kwargs: - content_entries = kwargs["contentEntries"] if "activityMetadata" in kwargs: activity_metadata = kwargs["activityMetadata"] if "deviceMetadata" in kwargs: @@ -632,10 +630,11 @@ def __init__( protected_app_metadata = kwargs["protectedAppMetadata"] # Convert nested objects - entries = [ - e if isinstance(e, ProcessConversationMetadata) else ProcessConversationMetadata(**e) - for e in content_entries - ] + entry = ( + content_entry + if isinstance(content_entry, ProcessConversationMetadata) + else ProcessConversationMetadata(**content_entry) + ) if isinstance(activity_metadata, MutableMapping): activity_metadata = ActivityMetadata(**activity_metadata) if isinstance(device_metadata, MutableMapping): @@ -647,7 +646,7 @@ def __init__( # Call parent without explicit params with aliases super().__init__(**kwargs) - self.content_entries = entries + self.content_entries = [entry] self.activity_metadata = activity_metadata self.device_metadata = device_metadata self.integrated_app_metadata = integrated_app_metadata diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index f570ec13b96..6cc2264095d 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -272,17 +272,7 @@ async def _map_messages( correlation_id = (session_id or str(uuid.uuid4())) + "@AF" # This would be c# ticks equivalent and needs to fit inside c# long base_sequence_number = time.time_ns() // 100 + 621355968000000000 - content_entries: list[ProcessConversationMetadata | MutableMapping[str, Any]] = [ - ProcessConversationMetadata( - identifier=message_id if index == 0 else f"{message_id}-{index}", - content=purview_content, - name=f"Agent Framework Message {message_id}", - is_truncated=False, - correlation_id=correlation_id, - sequence_number=base_sequence_number + index, - ) - for index, purview_content in enumerate(_map_message_contents(m)) - ] + mapped_contents = _map_message_contents(m) activity_meta = ActivityMetadata(activity=activity) purview_app_location = self._settings.get("purview_app_location") @@ -312,21 +302,30 @@ async def _map_messages( ) ) - ctp = ContentToProcess( - content_entries=content_entries, - activity_metadata=activity_meta, - device_metadata=device_meta, - integrated_app_metadata=integrated_app, - protected_app_metadata=protected_app, - ) - req = ProcessContentRequest( - content_to_process=ctp, - user_id=resolved_user_id, # Use the resolved user_id for all messages - tenant_id=tenant_id, - correlation_id=correlation_id, - process_inline=None, # Will be set based on execution mode - ) - results.append(req) + for index, purview_content in enumerate(mapped_contents): + content_entry = ProcessConversationMetadata( + identifier=message_id if index == 0 else f"{message_id}-{index}", + content=purview_content, + name=f"Agent Framework Message {message_id}", + is_truncated=False, + correlation_id=correlation_id, + sequence_number=base_sequence_number + index, + ) + ctp = ContentToProcess( + content_entry=content_entry, + activity_metadata=activity_meta, + device_metadata=device_meta, + integrated_app_metadata=integrated_app, + protected_app_metadata=protected_app, + ) + req = ProcessContentRequest( + content_to_process=ctp, + user_id=resolved_user_id, # Use the resolved user_id for all messages + tenant_id=tenant_id, + correlation_id=correlation_id, + process_inline=None, # Will be set based on execution mode + ) + results.append(req) return results, resolved_user_id async def _process_with_scopes(self, pc_request: ProcessContentRequest) -> ProcessContentResponse: diff --git a/python/packages/purview/tests/purview/conftest.py b/python/packages/purview/tests/purview/conftest.py index dc9a7024e8c..0089053b7c9 100644 --- a/python/packages/purview/tests/purview/conftest.py +++ b/python/packages/purview/tests/purview/conftest.py @@ -41,7 +41,7 @@ def _create_content(text: str = "Test") -> ContentToProcess: protected_app = ProtectedAppMetadata(name="Protected", version="1.0", application_location=location) return ContentToProcess( - content_entries=[metadata], + content_entry=metadata, activity_metadata=activity_meta, device_metadata=device_meta, integrated_app_metadata=integrated_app, diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 5dbea7aaff5..db79a380cd9 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -146,8 +146,10 @@ async def test_map_messages_creates_requests( assert requests[0].tenant_id == "12345678-1234-1234-1234-123456789012" assert user_id == "12345678-1234-1234-1234-123456789012" - async def test_map_messages_submits_every_content_item(self, processor: ScopedContentProcessor) -> None: - """Test _map_messages submits every content item, not just the text. + async def test_map_messages_submits_each_content_item_in_separate_request( + self, processor: ScopedContentProcessor + ) -> None: + """Test _map_messages submits every content item in a separate request. Non-text content flattened to Message.text is empty for binary, tool-call and tool-result content, which would have Purview classify an empty string. @@ -173,10 +175,9 @@ async def test_map_messages_submits_every_content_item(self, processor: ScopedCo requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT) - assert len(requests) == 1 - entries = requests[0].content_to_process.content_entries - assert len(entries) == 3 - + assert len(requests) == 3 + entries = [request.content_to_process.content_entries[0] for request in requests] + assert all(len(request.content_to_process.content_entries) == 1 for request in requests) binary_entry, call_entry, result_entry = entries assert isinstance(binary_entry.content, PurviewBinaryContent) assert binary_entry.content.data == secret @@ -236,7 +237,9 @@ async def test_map_messages_submits_additional_properties_on_structured_content( requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT) - call_entry, result_entry = requests[0].content_to_process.content_entries + assert len(requests) == 2 + call_entry = requests[0].content_to_process.content_entries[0] + result_entry = requests[1].content_to_process.content_entries[0] assert isinstance(call_entry.content, PurviewTextContent) assert "120-98-1437" in call_entry.content.data diff --git a/python/packages/purview/tests/purview/test_purview_models.py b/python/packages/purview/tests/purview/test_purview_models.py index 4af581014d9..3e3e3fb929a 100644 --- a/python/packages/purview/tests/purview/test_purview_models.py +++ b/python/packages/purview/tests/purview/test_purview_models.py @@ -91,7 +91,7 @@ def test_content_to_process_with_nested_structures(self) -> None: protected_app = ProtectedAppMetadata(name="Protected", version="1.0", application_location=location) content = ContentToProcess( - content_entries=[metadata], + content_entry=metadata, activity_metadata=activity_meta, device_metadata=device_meta, integrated_app_metadata=integrated_app, @@ -188,7 +188,7 @@ def test_content_serialization_uses_aliases(self) -> None: protected_app = ProtectedAppMetadata(name="Protected", version="1.0", application_location=location) content = ContentToProcess( - content_entries=[metadata], + content_entry=metadata, activity_metadata=activity_meta, device_metadata=device_meta, integrated_app_metadata=integrated_app, @@ -198,6 +198,7 @@ def test_content_serialization_uses_aliases(self) -> None: dumped = content.model_dump(by_alias=True, exclude_none=True, mode="json") assert "contentEntries" in dumped + assert len(dumped["contentEntries"]) == 1 assert "activityMetadata" in dumped assert "deviceMetadata" in dumped assert "integratedAppMetadata" in dumped @@ -224,7 +225,7 @@ def test_process_content_request_excludes_private_fields(self) -> None: protected_app = ProtectedAppMetadata(name="Protected", version="1.0", application_location=location) content = ContentToProcess( - content_entries=[metadata], + content_entry=metadata, activity_metadata=activity_meta, device_metadata=device_meta, integrated_app_metadata=integrated_app, From b3675aca9268ad44e7e6c0f0364e41f5cfcec718 Mon Sep 17 00:00:00 2001 From: Eoin Doherty Date: Mon, 14 Sep 2026 18:05:55 -0700 Subject: [PATCH 4/5] Skip empty Purview content Avoid sending empty text or binary data to Graph processContent APIs in both .NET and Python. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ScopedContentProcessor.cs | 24 ++++++++-------- .../ScopedContentProcessorTests.cs | 28 +++++++++++++++++++ .../agent_framework_purview/_processor.py | 22 +++++++++------ .../purview/tests/purview/test_processor.py | 18 ++++++++++++ 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index 1572b91bcf0..a59d37786a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -242,11 +242,11 @@ private async Task> MapMessageToPCRequestsAsync(IEnu /// Map every content item of a message onto the Purview content type that fits it. /// /// The message whose contents should be evaluated. - /// One content item per evaluable , never empty. + /// One content item per evaluable . /// - /// Only is skipped, because it carries no user data. Everything - /// else is mapped to a real content item: submitting a message with part of its payload - /// unevaluated is a policy bypass. + /// and content with no data are skipped because they carry no user + /// data. Everything else is mapped to a real content item: submitting a message with part of + /// its payload unevaluated is a policy bypass. /// private static List MapMessageContents(ChatMessage message) { @@ -261,11 +261,6 @@ private static List MapMessageContents(ChatMessage message) } } - if (mapped.Count == 0) - { - mapped.Add(new PurviewTextContent(string.Empty)); - } - return mapped; } @@ -282,11 +277,18 @@ private static List MapMessageContents(ChatMessage message) // Telemetry only; there is nothing for DLP to classify. return null; + case TextContent { Text: null or "" }: + case TextReasoningContent { Text: null or "" }: + case DataContent { Data.IsEmpty: true }: + // Empty data is skipped because the Graph APIs do not support it. A request with + // empty data cannot violate content policies because it contains no content. + return null; + case TextContent textContent: - return new PurviewTextContent(textContent.Text ?? string.Empty); + return new PurviewTextContent(textContent.Text); case TextReasoningContent reasoningContent: - return new PurviewTextContent(reasoningContent.Text ?? string.Empty); + return new PurviewTextContent(reasoningContent.Text); case DataContent dataContent: return new PurviewBinaryContent(dataContent.Data.ToArray()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index 5fcd36a5b46..039dde63e7d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -1130,6 +1130,34 @@ await this._processor.ProcessMessagesAsync( Assert.NotEqual(binaryEntry.Identifier, functionCallEntry.Identifier); } + [Fact] + public async Task ProcessMessagesAsync_WithEmptyTextAndData_SkipsEmptyContentAsync() + { + // Arrange + byte[] emptyData = []; + List messages = + [ + new(ChatRole.User, + [ + new TextContent(string.Empty), + new DataContent(emptyData, "application/octet-stream") + ]) + ]; + PurviewSettings settings = CreateValidPurviewSettings(); + TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + /// /// Verifies a block verdict known from an offline cached scope is still enforced. The offline /// branch reports asynchronously, but discarding the scope's own policy actions would let a diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index 6cc2264095d..a2dd6fdac8f 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -109,12 +109,21 @@ def _map_content(content: Content) -> ContentBase | None: if content_type in _NON_EVALUATED_CONTENT_TYPES: return None + # Empty data is skipped because the Graph APIs do not support it. A request with + # empty data cannot violate content policies because it contains no content. if content_type in ("text", "text_reasoning"): - return PurviewTextContent(data=content.text or "") + if not content.text: + return None + return PurviewTextContent(data=content.text) if content_type == "data": - raw_data = _decode_data_uri(getattr(content, "uri", None)) + uri = getattr(content, "uri", None) + if not uri: + return None + raw_data = _decode_data_uri(uri) if raw_data is not None: + if not raw_data: + return None return PurviewBinaryContent(data=raw_data) # Not a base64 data URI after all: evaluate the serialized form rather than drop it. return PurviewTextContent(data=_serialize_for_evaluation(content.to_dict())) @@ -127,13 +136,8 @@ def _map_content(content: Content) -> ContentBase | None: def _map_message_contents(message: Message) -> list[ContentBase]: - """Map every content item of a message to Purview content entries. - - Always returns at least one entry so that a message can never pass through - without being submitted for evaluation. - """ - mapped = [purview_content for content in message.contents if (purview_content := _map_content(content))] - return mapped or [PurviewTextContent(data="")] + """Map every non-empty content item of a message to Purview content entries.""" + return [purview_content for content in message.contents if (purview_content := _map_content(content))] def _is_blocking_action(action_info: DlpActionInfo) -> bool: diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index db79a380cd9..551be829c42 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -193,6 +193,24 @@ async def test_map_messages_submits_each_content_item_in_separate_request( assert all(entry.content is not None for entry in entries) assert not any(isinstance(entry.content, PurviewTextContent) and entry.content.data == "" for entry in entries) + async def test_map_messages_skips_empty_text_and_data(self, processor: ScopedContentProcessor) -> None: + """Test _map_messages does not create requests for empty text or binary data.""" + from agent_framework import Content + + messages = [ + Message( + role="user", + contents=[ + "", + Content.from_data(data=b"", media_type="application/octet-stream"), + ], + ) + ] + + requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT) + + assert requests == [] + async def test_map_messages_decodes_data_uri_with_media_type_parameters( self, processor: ScopedContentProcessor ) -> None: From 0b2e438224f5000e8da4d6439af8aa91834b1c30 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:34:30 +0000 Subject: [PATCH 5/5] Address PR comments --- .../ScopedContentProcessor.cs | 37 +++++-- .../ScopedContentProcessorTests.cs | 84 ++++++++++++++++ .../agent_framework_purview/_processor.py | 16 ++- .../purview/tests/purview/test_processor.py | 99 +++++++++++++++++++ 4 files changed, 222 insertions(+), 14 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index a59d37786a6..a456f2d7891 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -23,6 +23,11 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor private readonly ICacheProvider _cacheProvider; private readonly IChannelHandler _channelHandler; + /// + /// The characters that terminate the authority of a URL, being the start of the path, query or fragment. + /// + private static readonly char[] s_authorityDelimiters = ['/', '?', '#']; + /// /// Create a new instance of . /// @@ -105,10 +110,10 @@ private static List GetBlockingActions(List action /// /// /// Application ids (GUIDs) and domain names are case-insensitive, so those fold whole. URL values - /// are not: the scheme and host are case-insensitive but the path and query are case-sensitive, so - /// folding a URL whole would let a scope for contoso.com/public match a request for - /// contoso.com/Public. Location types that are not recognized fold whole, which matches more - /// scopes rather than fewer. + /// are not. Only the scheme and the host are case-insensitive; the userinfo, path, query and + /// fragment are all case-sensitive, so folding a URL whole would let a scope for + /// contoso.com/public match a request for contoso.com/Public. Location types that are + /// not recognized fold whole, which matches more scopes rather than fewer. /// /// The location type segment, for example policyLocationUrl. /// The location value to normalize. @@ -121,12 +126,26 @@ private static string NormalizeLocationValue(string locationType, string value) } int schemeEnd = value.IndexOf("://", StringComparison.Ordinal); - int hostStart = schemeEnd >= 0 ? schemeEnd + 3 : 0; - int pathStart = value.IndexOf('/', hostStart); + int authorityStart = schemeEnd >= 0 ? schemeEnd + 3 : 0; + + // The authority ends at the first path, query or fragment delimiter, whichever comes first. + int authorityEnd = value.IndexOfAny(s_authorityDelimiters, authorityStart); + if (authorityEnd < 0) + { + authorityEnd = value.Length; + } - return pathStart < 0 - ? value.ToUpperInvariant() - : string.Concat(value.Substring(0, pathStart).ToUpperInvariant(), value.Substring(pathStart)); + // Credentials are case-sensitive, so only the host half of the authority folds. + string authority = value.Substring(authorityStart, authorityEnd - authorityStart); + int userInfoEnd = authority.LastIndexOf('@'); + string userInfo = userInfoEnd >= 0 ? authority.Substring(0, userInfoEnd + 1) : string.Empty; + string host = userInfoEnd >= 0 ? authority.Substring(userInfoEnd + 1) : authority; + + return string.Concat( + value.Substring(0, authorityStart).ToUpperInvariant(), + userInfo, + host.ToUpperInvariant(), + value.Substring(authorityEnd)); } private static bool TryGetUserIdFromPayload(IEnumerable messages, out string? userId) diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index 039dde63e7d..7afad015ca1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -351,6 +351,90 @@ public void CheckApplicableScopes_TreatsUrlLocationPathAsCaseSensitive() Assert.Empty(dlpActions); } + [Fact] + public void CheckApplicableScopes_TreatsUrlLocationQueryAsCaseSensitive() + { + // Arrange + ProcessContentRequest pcRequest = CreateProcessContentRequest(); + pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation = + new("microsoft.graph.policyLocationUrl", "https://contoso.com?label=Secret"); + ProtectionScopesResponse psResponse = new() + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = [new("#microsoft.graph.policyLocationUrl", "https://contoso.com?label=secret")], + ExecutionMode = ExecutionMode.EvaluateInline + } + ] + }; + + // Act + (bool shouldProcess, List dlpActions, _) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse); + + // Assert + Assert.False(shouldProcess); + Assert.Empty(dlpActions); + } + + [Fact] + public void CheckApplicableScopes_TreatsUrlLocationFragmentAsCaseSensitive() + { + // Arrange + ProcessContentRequest pcRequest = CreateProcessContentRequest(); + pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation = + new("microsoft.graph.policyLocationUrl", "https://contoso.com#Section"); + ProtectionScopesResponse psResponse = new() + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = [new("#microsoft.graph.policyLocationUrl", "https://contoso.com#section")], + ExecutionMode = ExecutionMode.EvaluateInline + } + ] + }; + + // Act + (bool shouldProcess, List dlpActions, _) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse); + + // Assert + Assert.False(shouldProcess); + Assert.Empty(dlpActions); + } + + [Fact] + public void CheckApplicableScopes_TreatsUrlLocationUserInfoAsCaseSensitive() + { + // Arrange + ProcessContentRequest pcRequest = CreateProcessContentRequest(); + pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation = + new("microsoft.graph.policyLocationUrl", "https://alice:SecretPass@contoso.com/docs"); + ProtectionScopesResponse psResponse = new() + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = [new("#microsoft.graph.policyLocationUrl", "https://alice:secretpass@contoso.com/docs")], + ExecutionMode = ExecutionMode.EvaluateInline + } + ] + }; + + // Act + (bool shouldProcess, List dlpActions, _) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse); + + // Assert + Assert.False(shouldProcess); + Assert.Empty(dlpActions); + } + [Fact] public void CheckApplicableScopes_MatchesApplicationLocationCaseInsensitively() { diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index a2dd6fdac8f..919e97723e3 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -159,16 +159,22 @@ def _normalize_location_value(data_type: str, value: str) -> str: """Normalize a policy location value for comparison, according to its location type. Application ids (GUIDs) and domain names are case-insensitive, so those fold whole. URL values are - not: the scheme and host are case-insensitive but the path and query are case-sensitive, so folding - a URL whole would let a scope for ``contoso.com/public`` match a request for ``contoso.com/Public``. - Location types that are not recognised fold whole, which matches more scopes rather than fewer. + not. Only the scheme and the host are case-insensitive; the userinfo, path, query and fragment are + all case-sensitive, so folding a URL whole would let a scope for ``contoso.com/public`` match a + request for ``contoso.com/Public``. Location types that are not recognised fold whole, which matches + more scopes rather than fewer. """ if data_type.split(".")[-1].casefold().endswith("url"): scheme, separator, remainder = value.partition("://") if not separator: scheme, separator, remainder = "", "", value - host, slash, path = remainder.partition("/") - return f"{scheme.casefold()}{separator}{host.casefold()}{slash}{path}" + # The authority ends at the first path, query or fragment delimiter, whichever comes first. + delimiter = re.search(r"[/?#]", remainder) + authority_end = delimiter.start() if delimiter else len(remainder) + authority, tail = remainder[:authority_end], remainder[authority_end:] + # Credentials are case-sensitive, so only the host half of the authority folds. + userinfo, at_sign, host = authority.rpartition("@") + return f"{scheme.casefold()}{separator}{userinfo}{at_sign}{host.casefold()}{tail}" return value.casefold() diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 551be829c42..f5d2fe81efb 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -444,6 +444,105 @@ async def test_check_applicable_scopes_treats_url_location_path_as_case_sensitiv assert should_process is False assert dlp_actions == [] + async def test_check_applicable_scopes_treats_url_location_query_as_case_sensitive( + self, process_content_request_factory + ) -> None: + """Test _check_applicable_scopes keeps URL query casing significant. + + A query value is case-sensitive, and it may follow the host directly with no path between + them, so the authority has to end at the query delimiter as well as the path delimiter. + """ + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + pc_request.content_to_process.protected_app_metadata.application_location = PolicyLocation( + data_type="microsoft.graph.policyLocationUrl", + value="https://contoso.com?label=Secret", + ) + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.EVALUATE_INLINE, + locations=[ + PolicyLocation( + data_type="#microsoft.graph.policyLocationUrl", + value="https://contoso.com?label=secret", + ) + ], + policy_actions=[DlpActionInfo(action=DlpAction.BLOCK_ACCESS)], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + + should_process, dlp_actions, _ = ScopedContentProcessor._check_applicable_scopes(pc_request, ps_response) + + assert should_process is False + assert dlp_actions == [] + + async def test_check_applicable_scopes_treats_url_location_fragment_as_case_sensitive( + self, process_content_request_factory + ) -> None: + """Test _check_applicable_scopes keeps URL fragment casing significant. + + A fragment may follow the host directly, so the authority has to end at the fragment + delimiter as well. + """ + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + pc_request.content_to_process.protected_app_metadata.application_location = PolicyLocation( + data_type="microsoft.graph.policyLocationUrl", + value="https://contoso.com#Section", + ) + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.EVALUATE_INLINE, + locations=[ + PolicyLocation( + data_type="#microsoft.graph.policyLocationUrl", + value="https://contoso.com#section", + ) + ], + policy_actions=[DlpActionInfo(action=DlpAction.BLOCK_ACCESS)], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + + should_process, dlp_actions, _ = ScopedContentProcessor._check_applicable_scopes(pc_request, ps_response) + + assert should_process is False + assert dlp_actions == [] + + async def test_check_applicable_scopes_treats_url_location_userinfo_as_case_sensitive( + self, process_content_request_factory + ) -> None: + """Test _check_applicable_scopes keeps URL userinfo casing significant. + + Credentials embedded in the authority are case-sensitive, so only the host half of the + authority may be folded. + """ + from agent_framework_purview._models import ProtectionScopesResponse + + pc_request = process_content_request_factory() + pc_request.content_to_process.protected_app_metadata.application_location = PolicyLocation( + data_type="microsoft.graph.policyLocationUrl", + value="https://alice:SecretPass@contoso.com/docs", + ) + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + execution_mode=ExecutionMode.EVALUATE_INLINE, + locations=[ + PolicyLocation( + data_type="#microsoft.graph.policyLocationUrl", + value="https://alice:secretpass@contoso.com/docs", + ) + ], + policy_actions=[DlpActionInfo(action=DlpAction.BLOCK_ACCESS)], + ) + ps_response = ProtectionScopesResponse(scopes=[scope]) + + should_process, dlp_actions, _ = ScopedContentProcessor._check_applicable_scopes(pc_request, ps_response) + + assert should_process is False + assert dlp_actions == [] + async def test_combine_policy_actions(self, processor: ScopedContentProcessor) -> None: """Test _combine_policy_actions merges action lists.""" action1 = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restrictionAction=RestrictionAction.BLOCK)