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)