From 32fd9337b3b5f05ac6c6bad4a92c4ee98d567932 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala <12496535+DheerajPannala@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:00:42 +0100 Subject: [PATCH 1/2] Add Microsoft Purview DLP real-time protection on agent-hooks PurviewDlpClient sends a text to the Microsoft Graph processContent API as uploadText or downloadText: one conversation entry with the agent, the session and its sequence, scoped to the agent's application, and a new client-request-id per call. A policy action restricted with block blocks; processing errors, failures and responses of another shape follow the fail mode, and an allow of truncated text is not authoritative. One deadline covers token acquisition and the request; HTTPS only, no redirects. PurviewDlpTokenResolvers.FromAgenticUser evaluates as the agentic user (/me) through IAgenticTokenProvider.GetAgenticUserTokenAsync; FromAccessTokenProvider takes a host-supplied Graph token. A365PurviewInterceptor gates the user's message at input and audits the reply at output in the background (enforce on request). CreateProtectionEmitter takes optional Purview options so Defender and Purview compose on one emitter. Co-authored-by: Dominik Bezic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94b4d34e-4cd4-4156-afd9-b486121d2b41 --- CHANGELOG.md | 26 + docs/design.md | 10 +- .../Protection/PurviewDlpClientTests.cs | 950 ++++++++++++++++++ .../PurviewDlpTokenResolversTests.cs | 181 ++++ .../A365PurviewInterceptorTests.cs | 723 +++++++++++++ .../Protection/Purview/PurviewDlpClient.cs | 729 ++++++++++++++ .../Purview/PurviewDlpEvaluationResult.cs | 85 ++ .../Protection/Purview/PurviewDlpOptions.cs | 243 +++++ .../Purview/PurviewDlpTokenResolver.cs | 122 +++ .../Purview/PurviewDlpTokenResolvers.cs | 95 ++ src/Tooling/Core/docs/design.md | 52 +- .../AgentHooks/A365DefenderInterceptor.cs | 45 +- .../AgentHooks/A365PurviewInterceptor.cs | 363 +++++++ ....A365.Tooling.Extensions.AgentHooks.csproj | 6 +- src/Tooling/Extensions/AgentHooks/README.md | 161 ++- 15 files changed, 3767 insertions(+), 24 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs create mode 100644 src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs create mode 100644 src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs create mode 100644 src/Tooling/Core/Protection/Purview/PurviewDlpOptions.cs create mode 100644 src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs create mode 100644 src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs create mode 100644 src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b331d665..30fc47ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,32 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. agent-hooks interceptor (`ResponsibleAI.AgentHooks` 0.1.0-beta.1) for Defender, and `A365AgentHooks.CreateProtectionEmitter` (`parallel/strictest`). When no agent identity is resolved, or resolving it fails, the interceptor follows the fail mode. +- **Microsoft.Agents.A365.Tooling** - Microsoft Purview data loss prevention (DLP) and audit client + - `PurviewDlpClient.EvaluateAsync` sends a text to the Microsoft Graph `processContent` API + (`POST {base}/me/dataSecurityAndGovernance/processContent`) as `uploadText` (content entering the agent) or + `downloadText` (its reply): one conversation entry with the agent, the session (`correlationId`) and its + sequence, scoped to the agent's application (the blueprint id by default). A policy action whose + `restrictionAction` is `block` blocks; other actions allow and are counted. Each call sends a new + `client-request-id`, returned as `PurviewDlpEvaluationResult.CorrelationId`. + - Tokens come from a `PurviewDlpTokenResolver`: `PurviewDlpTokenResolvers.FromAgenticUser` uses the agent's + connection (`IAgenticTokenProvider.GetAgenticUserTokenAsync`) for the agentic user's delegated Microsoft Graph + token (`Content.Process.User`) and evaluates as `/me`; `FromAccessTokenProvider` takes a host-supplied token, for + `/me` or for a given user (`/users/{id}`). + - Configured with `ENABLE_A365_PURVIEW_DLP`, `A365_PURVIEW_DLP_GRAPH_BASE_URL` (default + `https://graph.microsoft.com/v1.0`, absolute HTTPS only), `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE`, + `A365_PURVIEW_DLP_FAIL_MODE` (`open` or `closed`), `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` (one deadline for token + acquisition and the request), `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` (default 100000) and + `A365_PURVIEW_DLP_RESPONSE_MODE` (`audit` or `enforce`); any other value is rejected. Failures, and processing + errors Graph reports inline, follow the fail mode, recording only the exception type or status, never a response + body. Longer text is cut and sent with `isTruncated`: Purview's block stands and its allow follows the fail mode. + Redirects are not followed, and lone surrogates are sent as U+FFFD. +- **Microsoft.Agents.A365.Tooling.Extensions.AgentHooks** - `A365PurviewInterceptor` (`AddA365Purview`), an + agent-hooks interceptor for Purview DLP: the user's message at `input` is evaluated as `uploadText` and a block + denies it (`purview:block`); the reply at `output` is evaluated as `downloadText`, for audit in the background by + default or waiting for the verdict with `A365_PURVIEW_DLP_RESPONSE_MODE=enforce`. Missing verdicts follow the fail + mode (`purview:unverified` or `runtime_error:purview_unverified`). `A365AgentHooks.CreateProtectionEmitter` takes + optional `PurviewDlpOptions`, so Defender and Purview compose on one emitter whose interceptor timeout exceeds + the longer of their timeouts. - **Microsoft.Agents.A365.Tooling** - V1/V2 per-audience token support for MCP servers - `MCPServerConfig` extended with `audience`, `scope`, `publisher`, and `Headers` fields - `IMcpTokenProvider` interface for pluggable OAuth token acquisition diff --git a/docs/design.md b/docs/design.md index 75060c6d..0e5f0256 100644 --- a/docs/design.md +++ b/docs/design.md @@ -36,7 +36,7 @@ src/ │ ├── SemanticKernel/ # SK tool registration │ ├── AgentFramework/ # AF tool registration │ ├── AzureAIFoundry/ # Azure AI Foundry integration -│ └── AgentHooks/ # agent-hooks Defender interceptor (preview) +│ └── AgentHooks/ # agent-hooks Defender and Purview interceptors (preview) └── Tests/ # Test projects docs/ └── design.md # This file @@ -53,11 +53,11 @@ docs/ | `Microsoft.Agents.A365.Observability.Extensions.SemanticKernel` | Semantic Kernel telemetry integration | [design.md](../src/Observability/Extensions/SemanticKernel/docs/design.md) | | `Microsoft.Agents.A365.Observability.Extensions.OpenAI` | OpenAI SDK telemetry integration | [design.md](../src/Observability/Extensions/OpenAI/docs/design.md) | | `Microsoft.Agents.A365.Notifications` | Microsoft 365 notification handling | [design.md](../src/Notification/Microsoft.Agents.A365.Notifications/docs/design.md) | -| `Microsoft.Agents.A365.Tooling` | MCP server discovery and configuration; Microsoft Defender for AI real-time protection client | [design.md](../src/Tooling/Core/docs/design.md) | +| `Microsoft.Agents.A365.Tooling` | MCP server discovery and configuration; Microsoft Defender for AI real-time protection and Microsoft Purview DLP clients | [design.md](../src/Tooling/Core/docs/design.md) | | `Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel` | Semantic Kernel MCP tool registration | [design.md](../src/Tooling/Extensions/SemanticKernel/docs/design.md) | | `Microsoft.Agents.A365.Tooling.Extensions.AgentFramework` | Agent Framework MCP tool registration | [design.md](../src/Tooling/Extensions/AgentFramework/docs/design.md) | | `Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry` | Azure AI Foundry MCP tool registration | [design.md](../src/Tooling/Extensions/AzureAIFoundry/docs/design.md) | -| `Microsoft.Agents.A365.Tooling.Extensions.AgentHooks` | Microsoft Defender for AI interceptor for agent-hooks hosts (preview) | [README.md](../src/Tooling/Extensions/AgentHooks/README.md) | +| `Microsoft.Agents.A365.Tooling.Extensions.AgentHooks` | Microsoft Defender for AI and Microsoft Purview DLP interceptors for agent-hooks hosts (preview) | [README.md](../src/Tooling/Extensions/AgentHooks/README.md) | ## Core Package Documentation @@ -340,6 +340,10 @@ else if (EnvironmentUtils.IsDevelopmentEnvironment()) | `ENABLE_A365_DEFENDER_RTP` | Enable Microsoft Defender for AI real-time protection (`DefenderRtpOptions.FromEnvironment`); a value that is not true or false is rejected | `false` | | `A365_DEFENDER_RTP_ENDPOINT` | Defender prevention endpoint, `https:///v1/protection/evaluate` | - | | `A365_DEFENDER_RTP_FAIL_MODE` | `open` or `closed` (blocks when Defender returns no verdict); any other value is rejected | open | +| `ENABLE_A365_PURVIEW_DLP` | Enable Microsoft Purview DLP and audit (`PurviewDlpOptions.FromEnvironment`); a value that is not true or false is rejected | `false` | +| `A365_PURVIEW_DLP_GRAPH_BASE_URL` | Microsoft Graph base URL for `processContent`, an absolute HTTPS URL | `https://graph.microsoft.com/v1.0` | +| `A365_PURVIEW_DLP_FAIL_MODE` | `open` or `closed` (blocks when Purview returns no verdict); any other value is rejected | open | +| `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` (replies are sent to Purview without waiting) or `enforce` (replies wait for the verdict); any other value is rejected | audit | ## Testing diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs new file mode 100644 index 00000000..2d7ec1d1 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs @@ -0,0 +1,950 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using FluentAssertions; +using Microsoft.Agents.A365.Tooling.Protection.Purview; +using Xunit; + +namespace Microsoft.Agents.A365.Tooling.Core.Tests.Protection; + +public class PurviewDlpClientTests +{ + private const string GraphBase = "https://graph.example.test/v1.0"; + private const string ProcessContentUrl = GraphBase + "/me/dataSecurityAndGovernance/processContent"; + private const string AgentId = "aaaaaaaa-0000-4000-8000-000000000001"; + private const string TenantId = "bbbbbbbb-0000-4000-8000-000000000002"; + private const string BlueprintId = "cccccccc-0000-4000-8000-000000000003"; + private const string AgenticUserId = "dddddddd-0000-4000-8000-000000000004"; + private const string AccessToken = "graph-access-token"; + private const string Payload = "BLOCK_ME"; + + internal const string Clean = """{"protectionScopeState":"modified","policyActions":[],"processingErrors":[]}"""; + + internal const string Blocked = """ + {"protectionScopeState":"modified","policyActions":[{"@odata.type":"#microsoft.graph.restrictAccessAction","action":"restrictAccess","restrictionAction":"block"}],"processingErrors":[]} + """; + + internal const string ProcessingError = """ + {"protectionScopeState":"notModified","policyActions":[],"processingErrors":[{"code":"BadRequest","message":"Invalid request field. The provided data for Name is invalid.","target":null,"errorType":"permanent","innerError":null,"details":[]}]} + """; + + private static readonly PurviewDlpTokenResolver Tokens = (_, _, _) => Task.FromResult(new PurviewDlpToken(AccessToken)); + + // ─── request ───────────────────────────────────────────────────────────── + + [Fact] + public async Task ReturnsNullWithoutCallsWhenDisabled() + { + var handler = new GraphHandler(_ => Graph(Clean)); + var resolved = 0; + var client = new PurviewDlpClient(new PurviewDlpOptions { Enabled = false, GraphBaseUrl = new Uri(GraphBase) }, new HttpClient(handler)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), (_, _, _) => + { + resolved++; + return Task.FromResult(new PurviewDlpToken(AccessToken)); + }); + + result.Should().BeNull(); + handler.Calls.Should().BeEmpty(); + resolved.Should().Be(0); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \n\t ")] + public async Task ReturnsNullWithoutCallsForEmptyText(string? text) + { + var (client, handler) = Create(_ => Graph(Clean)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, text, Agent(), Tokens); + + result.Should().BeNull(); + handler.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task SendsTheTextAsAConversationEntryWithAUniqueClientRequestId() + { + var clock = new ManualClock { Now = new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.FromHours(2)) }; + var handler = new GraphHandler(_ => Graph(Clean)); + var client = new PurviewDlpClient(Options(), new HttpClient(handler), timeProvider: clock); + + var first = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "Find flights to Paris", Agent(), Tokens); + var second = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "And a hotel", Agent(), Tokens); + + handler.Calls.Should().HaveCount(2); + var call = handler.Calls[0]; + call.Method.Should().Be(HttpMethod.Post); + call.Uri.Should().Be(new Uri(ProcessContentUrl)); + call.Authorization.Should().Be("Bearer " + AccessToken); + call.ContentType.Should().Be("application/json"); + Guid.TryParse(call.ClientRequestId, out _).Should().BeTrue(); + handler.Calls[1].ClientRequestId.Should().NotBe(call.ClientRequestId); + first!.CorrelationId.Should().Be(call.ClientRequestId); + second!.CorrelationId.Should().Be(handler.Calls[1].ClientRequestId); + + var entry = call.Entry; + Guid.TryParse(entry["identifier"]!.GetValue(), out _).Should().BeTrue(); + entry["identifier"]!.GetValue().Should().NotBe(call.ClientRequestId); + var expected = JsonNode.Parse($$""" + { + "contentToProcess": { + "contentEntries": [{ + "@odata.type": "microsoft.graph.processConversationMetadata", + "identifier": "{{entry["identifier"]!.GetValue()}}", + "content": { "@odata.type": "microsoft.graph.textContent", "data": "Find flights to Paris" }, + "agents": [{ "@odata.type": "microsoft.graph.aiAgentInfo", "blueprintId": "{{BlueprintId}}", "identifier": "{{AgentId}}", "name": "SampleAgent", "version": "1.0" }], + "name": "SampleAgent uploadText", + "correlationId": "conversation-1", + "sequenceNumber": 7, + "isTruncated": false, + "createdDateTime": "2026-10-07T10:00:00.000Z", + "modifiedDateTime": "2026-10-07T10:00:00.000Z", + "contentCategory": "ai" + }], + "activityMetadata": { "activity": "uploadText" }, + "integratedAppMetadata": { "name": "SampleAgent", "version": "1.0" }, + "protectedAppMetadata": { + "name": "SampleAgent", "version": "1.0", + "applicationLocation": { "@odata.type": "microsoft.graph.policyLocationApplication", "value": "{{BlueprintId}}" } + } + } + } + """); + JsonNode.DeepEquals(call.Body, expected).Should().BeTrue("the request body is {0}", call.Body.ToJsonString()); + + first.Should().BeEquivalentTo(new + { + Allowed = true, + Evaluated = true, + Truncated = false, + Activity = PurviewDlpActivity.UploadText, + SessionId = "conversation-1", + ProtectionScopeState = "modified", + HttpStatus = 200, + Error = (string?)null, + BlockReason = (string?)null, + Decision = new { BlockAction = false, RestrictionAction = (string?)null, ActionCount = 0 }, + }); + } + + [Fact] + public async Task ReportsTheReplyAsDownloadText() + { + var (client, handler) = Create(_ => Graph(Clean)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.DownloadText, "Here are three flights.", Agent(), Tokens); + + result!.Activity.Should().Be(PurviewDlpActivity.DownloadText); + var call = handler.Calls.Single(); + call.Body["contentToProcess"]!["activityMetadata"]!["activity"]!.GetValue().Should().Be("downloadText"); + call.Entry["name"]!.GetValue().Should().Be("SampleAgent downloadText"); + } + + [Theory] + [InlineData("app-id", BlueprintId, AgentId, "app-id")] + [InlineData(null, BlueprintId, AgentId, BlueprintId)] + [InlineData(" ", null, AgentId, AgentId)] + public async Task ScopesThePolicyLocationToTheApplicationThenTheBlueprintThenTheAgent( + string? applicationId, + string? blueprintId, + string? agentId, + string expected) + { + var (client, handler) = Create(_ => Graph(Clean)); + var agent = Agent(); + agent.ApplicationId = applicationId; + agent.BlueprintId = blueprintId; + agent.AgentId = agentId; + + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", agent, Tokens); + + var body = handler.Calls.Single().Body; + body["contentToProcess"]!["protectedAppMetadata"]!["applicationLocation"]!["value"]!.GetValue().Should().Be(expected); + } + + [Fact] + public async Task DeclaresTheAgentWithoutABlueprintItDoesNotKnow() + { + var (client, handler) = Create(_ => Graph(Clean)); + var agent = Agent(); + agent.BlueprintId = null; + + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", agent, Tokens); + + handler.Calls.Single().Entry["agents"]!.ToJsonString().Should().Be( + $$"""[{"@odata.type":"microsoft.graph.aiAgentInfo","identifier":"{{AgentId}}","name":"SampleAgent","version":"1.0"}]"""); + } + + [Theory] + [InlineData(null)] + [InlineData(" ")] + public async Task NamesTheEntryEvenWithoutAnAgentName(string? agentName) + { + var (client, handler) = Create(_ => Graph(Clean)); + var agent = Agent(); + agent.AgentName = agentName; + + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", agent, Tokens); + + var body = handler.Calls.Single().Body; + body["contentToProcess"]!["contentEntries"]![0]!["name"]!.GetValue().Should().Be("agent365-agent uploadText", "Graph rejects an entry without a name"); + body["contentToProcess"]!["protectedAppMetadata"]!["name"]!.GetValue().Should().Be(PurviewDlpClient.DefaultAgentName); + } + + [Fact] + public async Task NumbersEachSessionsEvaluationsWhenNoSequenceIsGiven() + { + var (client, handler) = Create(_ => Graph(Clean)); + + foreach (var session in new[] { "s-1", "s-1", "s-2", "s-1" }) + { + var agent = Agent(sequence: null); + agent.SessionId = session; + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", agent, Tokens); + } + + handler.Calls.Select(call => call.Entry["sequenceNumber"]!.GetValue()).Should().Equal(0, 1, 0, 2); + } + + [Fact] + public async Task KeepsTheSequenceIncreasingForASessionThatWasDropped() + { + var handler = new GraphHandler(_ => Graph(Clean)); + var client = new PurviewDlpClient(Options(), new HttpClient(handler)) { MaxTrackedSessions = 2 }; + async Task Next(string session) + { + var agent = Agent(sequence: null); + agent.SessionId = session; + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", agent, Tokens); + return handler.Calls[^1].Entry["sequenceNumber"]!.GetValue(); + } + + var first = await Next("s-1"); + await Next("s-1"); + await Next("s-2"); + await Next("s-3"); + await Next("s-4"); + var returned = await Next("s-1"); + + first.Should().Be(0); + returned.Should().BeGreaterThan(1, "a session that was dropped never repeats or goes back"); + } + + [Fact] + public async Task UsesTheGivenSequenceAndIgnoresANegativeOne() + { + var (client, handler) = Create(_ => Graph(Clean)); + + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(sequence: 42), Tokens); + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(sequence: -1), Tokens); + + handler.Calls.Select(call => call.Entry["sequenceNumber"]!.GetValue()).Should().Equal(42, 0); + } + + [Fact] + public async Task RequiresASessionAndAnApplication() + { + var (client, handler) = Create(_ => Graph(Clean)); + var withoutSession = Agent(); + withoutSession.SessionId = " "; + var withoutApplication = new PurviewDlpAgentContext { SessionId = "s-1" }; + + var noSession = () => client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", withoutSession, Tokens); + var noApplication = () => client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", withoutApplication, Tokens); + + (await noSession.Should().ThrowAsync()).Which.Message.Should().Contain("SessionId"); + (await noApplication.Should().ThrowAsync()).Which.Message.Should().Contain("ApplicationId"); + handler.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task EvaluatesForTheUserTheTokenNames() + { + var handler = new GraphHandler(_ => Graph(Clean)); + var client = new PurviewDlpClient(Options(new PurviewDlpOptions { GraphBaseUrl = new Uri("https://graph.example.test/beta/") }), new HttpClient(handler)); + PurviewDlpTokenResolver appOnly = (_, _, _) => Task.FromResult(new PurviewDlpToken(AccessToken, "user/../admin@contoso.example")); + + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), appOnly); + + handler.Calls.Single().Uri.AbsoluteUri.Should().Be( + "https://graph.example.test/beta/users/user%2F..%2Fadmin%40contoso.example/dataSecurityAndGovernance/processContent"); + } + + [Fact] + public async Task ReplacesLoneSurrogatesSoGraphCanParseTheRequest() + { + var (client, handler) = Create(_ => Graph(Clean)); + var agent = Agent(); + agent.AgentName = "Agent\uD800"; + + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "a\uDC00b \uD83D\uDE00 c\uD800", agent, Tokens); + + var call = handler.Calls.Single(); + call.Text.Should().Be("a\uFFFDb \uD83D\uDE00 c\uFFFD", "valid pairs are kept"); + call.Entry["name"]!.GetValue().Should().Be("Agent\uFFFD uploadText"); + } + + // ─── verdict ───────────────────────────────────────────────────────────── + + [Fact] + public async Task BlocksWhenAPolicyActionRestrictsWithBlock() + { + var (client, _) = Create(_ => Graph(Blocked)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "card number", Agent(), Tokens); + + result.Should().BeEquivalentTo(new + { + Allowed = false, + Evaluated = true, + Truncated = false, + HttpStatus = 200, + Error = (string?)null, + BlockReason = "The request was blocked by a Microsoft Purview data loss prevention policy.", + Decision = new { BlockAction = true, RestrictionAction = "block", ActionCount = 1 }, + }); + } + + [Fact] + public async Task BlocksAReplyWithItsOwnMessage() + { + var (client, _) = Create(_ => Graph(Blocked)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.DownloadText, "card number", Agent(), Tokens); + + result!.Allowed.Should().BeFalse(); + result.BlockReason.Should().Be("The response was blocked by a Microsoft Purview data loss prevention policy."); + } + + [Fact] + public async Task MatchesTheBlockActionInAnyCase() + { + var (client, _) = Create(_ => Graph("""{"policyActions":[{"action":"restrictAccess","restrictionAction":"warn"},{"action":"restrictAccess","restrictionAction":"BLOCK"}]}""")); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Allowed.Should().BeFalse(); + result.Decision.Should().BeEquivalentTo(new { BlockAction = true, RestrictionAction = "BLOCK", ActionCount = 2 }); + } + + [Fact] + public async Task AllowsActionsThatDoNotBlockAndCountsThem() + { + var (client, _) = Create(_ => Graph(""" + {"policyActions":[{"action":"restrictAccess","restrictionAction":"audit"},{"action":"restrictAccess","restrictionAction":"warn"},{"action":"notifyUser"}],"processingErrors":[]} + """)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Allowed.Should().BeTrue(); + result.Evaluated.Should().BeTrue(); + result.Decision.Should().BeEquivalentTo(new { BlockAction = false, RestrictionAction = (string?)null, ActionCount = 3 }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TreatsProcessingErrorsAsNoVerdict(bool failClosed) + { + var (client, _) = Create(_ => Graph(ProcessingError), new PurviewDlpOptions { FailClosed = failClosed }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse("a permanent BadRequest is reported with HTTP 200 and no policy actions"); + result.Allowed.Should().Be(!failClosed); + result.HttpStatus.Should().Be(200); + result.ProtectionScopeState.Should().Be("notModified"); + result.Error.Should().Be("processing errors: 1 (BadRequest, permanent)", "only identifier-like codes are kept, never the message"); + result.BlockReason.Should().Be(failClosed ? "Data loss prevention validation is unavailable and this agent is configured to fail closed." : null); + } + + [Fact] + public async Task KeepsABlockReportedWithProcessingErrors() + { + var (client, _) = Create(_ => Graph(""" + {"policyActions":[{"restrictionAction":"block"}],"processingErrors":[{"code":"PartialContent","message":"Some content was skipped."}]} + """)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeTrue(); + result.Allowed.Should().BeFalse(); + } + + [Fact] + public async Task ReportsProcessingErrorsWithoutTheirText() + { + var (client, _) = Create(_ => Graph(""" + {"policyActions":[],"processingErrors":[{"code":"has spaces and secrets","errorType":"transient"},"unexpected",{"code":"BadRequest","code":"Repeated"}]} + """)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("processing errors: 3 (transient)", "an error whose properties repeat still counts, unread"); + } + + [Theory] + [InlineData("""{"policyActions":[{"restrictionAction":"block"},"unexpected"]}""")] + [InlineData("""{"policyActions":["unexpected",{"restrictionAction":"Block"}]}""")] + [InlineData("""{"policyActions":[{"restrictionAction":{"value":"audit"}},{"restrictionAction":"block"}]}""")] + [InlineData("""{"policyActions":[{"action":"a","action":"b"},{"restrictionAction":"block"}]}""")] + public async Task KeepsABlockWhateverShapeTheOtherActionsHave(string body) + { + var (client, _) = Create(_ => Graph(body)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeTrue(); + result.Allowed.Should().BeFalse("an action that blocks is never outweighed by one Purview did not send in its usual shape"); + result.Decision.BlockAction.Should().BeTrue(); + result.Decision.ActionCount.Should().Be(2); + } + + [Theory] + [InlineData(HttpStatusCode.Accepted)] + [InlineData(HttpStatusCode.NoContent)] + public async Task TreatsAnAcceptedRequestWithoutAVerdictAsAnAllow(HttpStatusCode status) + { + var (client, _) = Create(_ => new HttpResponseMessage(status)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeTrue(); + result.Allowed.Should().BeTrue(); + result.HttpStatus.Should().Be((int)status); + result.Decision.ActionCount.Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeOnAnHttpErrorWithoutReadingItsBody(bool failClosed) + { + var (client, _) = Create( + _ => Graph("""{"error":{"code":"Forbidden","message":"Echo: card 4111 and token graph-access-token"}}""", HttpStatusCode.Forbidden), + new PurviewDlpOptions { FailClosed = failClosed }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().Be(!failClosed); + result.HttpStatus.Should().Be(403); + result.Error.Should().Be("http 403"); + } + + [Theory] + [InlineData("[]", "response contained no verdict")] + [InlineData("\"allow\"", "response contained no verdict")] + [InlineData("{}", "response contained no verdict")] + [InlineData("""{"value":[]}""", "response contained no verdict")] + [InlineData("""{"error":{"code":"InternalServerError"}}""", "response contained no verdict")] + [InlineData("""{"policyActions":null,"processingErrors":[]}""", "response contained no verdict")] + [InlineData("""{"policyActions":{"restrictionAction":"block"}}""", "response contained unexpected policyActions")] + [InlineData("""{"policyActions":["block"]}""", "response contained unexpected policyActions")] + [InlineData("""{"policyActions":[{"restrictionAction":{"value":"block"}}]}""", "response contained unexpected policyActions")] + [InlineData("""{"policyActions":[{"restrictionAction":"audit","restrictionAction":"block"}]}""", "response contained unexpected policyActions")] + [InlineData("""{"policyActions":[],"processingErrors":{"code":"BadRequest"}}""", "response contained unexpected processingErrors")] + [InlineData("""{"policyActions":[{"restrictionAction":"block"}],"policyActions":[]}""", "response contained duplicate properties")] + [InlineData("not json", "non-JSON response")] + [InlineData("", "non-JSON response")] + public async Task TreatsAResponseOfAnotherShapeAsNoVerdict(string body, string error) + { + var (client, _) = Create(_ => Graph(body), new PurviewDlpOptions { FailClosed = true }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().BeFalse(); + result.Error.Should().Be(error); + } + + [Fact] + public async Task ReadsAResponseWithoutOptionalMembers() + { + var (client, _) = Create(_ => Graph("""{"policyActions":[],"protectionScopeState":7}""")); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeTrue(); + result.Allowed.Should().BeTrue(); + result.ProtectionScopeState.Should().BeNull(); + } + + // ─── truncation ────────────────────────────────────────────────────────── + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DoesNotLetAnAllowOfATruncatedCopyAuthorizeTheText(bool failClosed) + { + var (client, handler) = Create(DeniesThePayload, new PurviewDlpOptions { MaxContentCharacters = 100, FailClosed = failClosed }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, new string('a', 100) + Payload, Agent(), Tokens); + + var call = handler.Calls.Single(); + call.Text.Should().Be(new string('a', 100)); + call.Entry["isTruncated"]!.GetValue().Should().BeTrue(); + result!.Evaluated.Should().BeTrue(); + result.Truncated.Should().BeTrue(); + result.Allowed.Should().Be(!failClosed); + result.Error.Should().Be(PurviewDlpClient.TruncatedContentError); + result.BlockReason.Should().Be(failClosed ? "Microsoft Purview could not evaluate all of the content and this agent is configured to fail closed." : null); + } + + [Fact] + public async Task KeepsABlockOfATruncatedCopy() + { + var (client, _) = Create(DeniesThePayload, new PurviewDlpOptions { MaxContentCharacters = 100 }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, Payload + new string('a', 200), Agent(), Tokens); + + result!.Truncated.Should().BeTrue(); + result.Allowed.Should().BeFalse(); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task AllowsTextWithinTheLimitNormally() + { + var (client, handler) = Create(DeniesThePayload, new PurviewDlpOptions { MaxContentCharacters = 100, FailClosed = true }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, new string('a', 100), Agent(), Tokens); + + handler.Calls.Single().Entry["isTruncated"]!.GetValue().Should().BeFalse(); + result!.Truncated.Should().BeFalse(); + result.Allowed.Should().BeTrue(); + } + + [Fact] + public async Task DoesNotSplitASurrogatePairWhenTruncating() + { + var (client, handler) = Create(_ => Graph(Clean), new PurviewDlpOptions { MaxContentCharacters = 4 }); + + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "abc\uD83D\uDE00def", Agent(), Tokens); + + handler.Calls.Single().Text.Should().Be("abc"); + } + + // ─── transport and deadline ────────────────────────────────────────────── + + [Fact] + public async Task ReportsATimeout() + { + var handler = new GraphHandler(async (_, cancellationToken) => + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + return Graph(Clean); + }); + var client = new PurviewDlpClient(Options(new PurviewDlpOptions { Timeout = TimeSpan.FromMilliseconds(100) }), new HttpClient(handler)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().BeTrue(); + result.Error.Should().Be("request timeout"); + } + + [Fact] + public async Task SharesOneDeadlineBetweenTokenAcquisitionAndTheRequest() + { + var requestWait = TimeSpan.Zero; + var handler = new GraphHandler(async (_, cancellationToken) => + { + var waiting = Stopwatch.StartNew(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + finally + { + requestWait = waiting.Elapsed; + } + + return Graph(Clean); + }); + var client = new PurviewDlpClient(Options(new PurviewDlpOptions { Timeout = TimeSpan.FromSeconds(1) }), new HttpClient(handler)); + PurviewDlpTokenResolver slow = async (_, _, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(700), cancellationToken); + return new PurviewDlpToken(AccessToken); + }; + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), slow); + + result!.Evaluated.Should().BeFalse(); + result.Error.Should().BeOneOf("request timeout", "entra token timeout"); + requestWait.Should().BeLessThan(TimeSpan.FromMilliseconds(700), "the request only gets what is left of the one deadline"); + } + + [Fact] + public async Task ReleasesAResolverThatIgnoresTheDeadline() + { + var (client, handler) = Create(_ => Graph(Clean), new PurviewDlpOptions { Timeout = TimeSpan.FromMilliseconds(200) }); + var never = new TaskCompletionSource(); + var watch = Stopwatch.StartNew(); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), (_, _, _) => never.Task); + + watch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(5)); + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("entra token timeout"); + handler.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task BoundsAResolverThatBlocksBeforeReturningItsTaskAndSendsNothingAfterTheDeadline() + { + // A handler that ignores cancellation, so only the client can keep a late token from being used. + var handler = new GraphHandler(async (_, _) => + { + await Task.Yield(); + return Graph(Clean); + }); + var client = new PurviewDlpClient(Options(new PurviewDlpOptions { Timeout = TimeSpan.FromMilliseconds(300) }), new HttpClient(handler)); + var watch = Stopwatch.StartNew(); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), (_, _, _) => + { + Thread.Sleep(1500); + return Task.FromResult(new PurviewDlpToken(AccessToken)); + }); + watch.Stop(); + await Task.Delay(1500); + + watch.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(1200), "the deadline bounds the resolver's synchronous work too"); + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("entra token timeout"); + handler.Calls.Should().BeEmpty("a token that arrives after the deadline is never used"); + } + + [Fact] + public async Task TreatsAResolverThatReturnsNoTaskAsNoToken() + { + var (client, handler) = Create(_ => Graph(Clean)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), (_, _, _) => null!); + + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("entra token unavailable (InvalidOperationException)"); + handler.Calls.Should().BeEmpty(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeOnAnyTransportException(bool failClosed) + { + var (client, _) = Create(_ => throw new CircuitOpenException(), new PurviewDlpOptions { FailClosed = failClosed }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().Be(!failClosed); + result.Error.Should().Be("request failed (CircuitOpenException)"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TreatsARedirectedRequestAsNoVerdict(bool failClosed) + { + var (client, _) = Create( + request => + { + // What a client that follows redirects does: the request ends at another host, which answers. + request.RequestUri = new Uri("https://elsewhere.example.test/v1.0/me/dataSecurityAndGovernance/processContent"); + return Graph(Clean); + }, + new PurviewDlpOptions { FailClosed = failClosed }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().Be(!failClosed); + result.Error.Should().Be("request was redirected"); + } + + [Fact] + public async Task TreatsARedirectResponseAsNoVerdict() + { + var (client, _) = Create(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.TemporaryRedirect); + response.Headers.Location = new Uri("https://elsewhere.example.test/"); + return response; + }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("http 307"); + } + + [Fact] + public async Task DoesNotSendToABaseUrlChangedToHttpAfterConstruction() + { + var (client, handler) = Create(_ => Graph(Clean)); + client.Options.GraphBaseUrl = new Uri("http://graph.example.test/v1.0"); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("graph base URL is not an absolute https URL"); + handler.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task LetsTheCallersCancellationThrough() + { + var (client, _) = Create(_ => Graph(Clean)); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + var act = () => client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens, cancelled.Token); + + await act.Should().ThrowAsync(); + } + + // ─── authentication ────────────────────────────────────────────────────── + + [Fact] + public async Task AsksTheResolverForTheConfiguredScopeOnEveryEvaluation() + { + var (client, handler) = Create(_ => Graph(Clean), new PurviewDlpOptions { AuthenticationScope = "https://graph.example.test/.default" }); + var requests = new List(); + PurviewDlpTokenResolver resolver = (agent, scope, _) => + { + requests.Add($"{agent.AgenticUserId}|{scope}"); + return Task.FromResult(new PurviewDlpToken(AccessToken)); + }; + + await client.EvaluateAsync(PurviewDlpActivity.UploadText, "one", Agent(), resolver); + await client.EvaluateAsync(PurviewDlpActivity.DownloadText, "two", Agent(), resolver); + + requests.Should().Equal(Enumerable.Repeat($"{AgenticUserId}|https://graph.example.test/.default", 2), "the resolver owns caching"); + handler.Calls.Should().HaveCount(2); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenNoTokenIsResolved(bool failClosed) + { + var (client, handler) = Create(_ => Graph(Clean), new PurviewDlpOptions { FailClosed = failClosed }); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), (_, _, _) => Task.FromResult(null)); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().Be(!failClosed); + result.Error.Should().Be("entra token unavailable"); + handler.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task RecordsOnlyTheTypeWhenTheResolverFails() + { + const string Secret = "client_secret=do-not-leak"; + var (client, handler) = Create(_ => Graph(Clean)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), (_, _, _) => throw new InvalidOperationException(Secret)); + + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("entra token unavailable (InvalidOperationException)"); + handler.Calls.Should().BeEmpty(); + } + + // ─── options ───────────────────────────────────────────────────────────── + + [Fact] + public void ReadsOptionsFromTheEnvironment() + { + var variables = new Dictionary + { + ["ENABLE_A365_PURVIEW_DLP"] = " TRUE ", + ["A365_PURVIEW_DLP_GRAPH_BASE_URL"] = "https://graph.example.test/beta", + ["A365_PURVIEW_DLP_AUTHENTICATION_SCOPE"] = "https://graph.example.test/.default", + ["A365_PURVIEW_DLP_FAIL_MODE"] = "Closed", + ["A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS"] = "2500", + ["A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS"] = "5000", + ["A365_PURVIEW_DLP_RESPONSE_MODE"] = "ENFORCE", + }; + + var options = PurviewDlpOptions.FromEnvironment(name => variables.TryGetValue(name, out var value) ? value : null); + + options.Should().BeEquivalentTo(new + { + Enabled = true, + GraphBaseUrl = new Uri("https://graph.example.test/beta"), + AuthenticationScope = "https://graph.example.test/.default", + FailClosed = true, + Timeout = TimeSpan.FromMilliseconds(2500), + MaxContentCharacters = 5000, + ResponseMode = PurviewDlpResponseMode.Enforce, + }); + } + + [Fact] + public void DefaultsToDisabledAuditAndFailOpen() + { + var options = PurviewDlpOptions.FromEnvironment(_ => null); + + options.Should().BeEquivalentTo(new + { + Enabled = false, + GraphBaseUrl = new Uri("https://graph.microsoft.com/v1.0"), + AuthenticationScope = "https://graph.microsoft.com/.default", + FailClosed = false, + Timeout = TimeSpan.FromSeconds(10), + MaxContentCharacters = 100000, + ResponseMode = PurviewDlpResponseMode.Audit, + }); + } + + [Theory] + [InlineData("true", true)] + [InlineData("1", true)] + [InlineData("Yes", true)] + [InlineData("on", true)] + [InlineData("false", false)] + [InlineData("0", false)] + [InlineData("no", false)] + [InlineData("OFF", false)] + [InlineData("", false)] + public void ReadsWhetherPurviewIsEnabled(string value, bool enabled) + { + PurviewDlpOptions.FromEnvironment(name => name == "ENABLE_A365_PURVIEW_DLP" ? value : null).Enabled.Should().Be(enabled); + } + + [Theory] + [InlineData("ENABLE_A365_PURVIEW_DLP", "enabled")] + [InlineData("A365_PURVIEW_DLP_FAIL_MODE", "close")] + [InlineData("A365_PURVIEW_DLP_RESPONSE_MODE", "block")] + [InlineData("A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS", "0")] + [InlineData("A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS", "1.5")] + [InlineData("A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS", "-1")] + [InlineData("A365_PURVIEW_DLP_GRAPH_BASE_URL", "http://graph.example.test/v1.0")] + [InlineData("A365_PURVIEW_DLP_GRAPH_BASE_URL", "graph.example.test/v1.0")] + [InlineData("A365_PURVIEW_DLP_GRAPH_BASE_URL", "https://graph.example.test/v1.0?api=1")] + [InlineData("A365_PURVIEW_DLP_GRAPH_BASE_URL", "https://graph.example.test/v1.0#me")] + public void RejectsAValueAVariableDoesNotAcceptRatherThanFallingBack(string name, string value) + { + var read = () => PurviewDlpOptions.FromEnvironment(variable => variable == name ? value : null); + + read.Should().Throw().WithMessage($"*{name}*"); + } + + [Fact] + public void RejectsOptionsAClientCannotUse() + { + var http = () => new PurviewDlpClient(new PurviewDlpOptions { GraphBaseUrl = new Uri("http://graph.example.test/v1.0") }); + var timeout = () => new PurviewDlpClient(new PurviewDlpOptions { Timeout = TimeSpan.Zero }); + var maximum = () => new PurviewDlpClient(new PurviewDlpOptions { MaxContentCharacters = 0 }); + var scope = () => new PurviewDlpClient(new PurviewDlpOptions { AuthenticationScope = " " }); + var mode = () => new PurviewDlpClient(new PurviewDlpOptions { ResponseMode = (PurviewDlpResponseMode)7 }); + + foreach (var create in new[] { http, timeout, maximum, scope, mode }) + { + create.Should().Throw(); + } + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + internal static PurviewDlpAgentContext Agent(long? sequence = 7) => new() + { + AgentId = AgentId, + TenantId = TenantId, + AgenticUserId = AgenticUserId, + BlueprintId = BlueprintId, + AgentName = "SampleAgent", + SessionId = "conversation-1", + SequenceNumber = sequence, + }; + + private static PurviewDlpOptions Options(PurviewDlpOptions? overrides = null) + { + var options = overrides ?? new PurviewDlpOptions(); + options.Enabled = true; + + // Tests that do not choose a Graph base use the fake one. + if (options.GraphBaseUrl == new Uri(PurviewDlpOptions.DefaultGraphBaseUrl)) + { + options.GraphBaseUrl = new Uri(GraphBase); + } + + return options; + } + + /// A fake Graph that blocks only text containing . + private static HttpResponseMessage DeniesThePayload(HttpRequestMessage request) + { + var body = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return Graph(body.Contains(Payload, StringComparison.Ordinal) ? Blocked : Clean); + } + + private static (PurviewDlpClient Client, GraphHandler Handler) Create( + Func respond, + PurviewDlpOptions? options = null) + { + var handler = new GraphHandler(respond); + return (new PurviewDlpClient(Options(options), new HttpClient(handler)), handler); + } + + internal static HttpResponseMessage Graph(string body, HttpStatusCode status = HttpStatusCode.OK) => new(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; +} + +/// Captures each Graph request (headers and body are read before the client disposes the request). +internal sealed class GraphHandler : HttpMessageHandler +{ + private readonly Func> _respond; + + public GraphHandler(Func respond) + : this((request, _) => Task.FromResult(respond(request))) + { + } + + public GraphHandler(Func> respond) + { + _respond = respond; + } + + public List Calls { get; } = new(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content == null ? "{}" : await request.Content.ReadAsStringAsync(cancellationToken); + var call = new GraphCall( + request.Method, + request.RequestUri!, + request.Headers.Authorization?.ToString(), + request.Headers.TryGetValues(PurviewDlpClient.ClientRequestIdHeader, out var values) ? values.Single() : null, + request.Content?.Headers.ContentType?.MediaType, + JsonNode.Parse(body)!.AsObject()); + + // Some tests send concurrently. + lock (Calls) + { + Calls.Add(call); + } + + return await _respond(request, cancellationToken); + } +} + +internal sealed record GraphCall(HttpMethod Method, Uri Uri, string? Authorization, string? ClientRequestId, string? ContentType, JsonObject Body) +{ + public JsonObject Entry => Body["contentToProcess"]!["contentEntries"]![0]!.AsObject(); + + public string Text => Entry["content"]!["data"]!.GetValue(); +} diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs new file mode 100644 index 00000000..29ab48e5 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.Tooling.Protection.Purview; +using Microsoft.Agents.Authentication; +using Moq; +using Xunit; + +namespace Microsoft.Agents.A365.Tooling.Core.Tests.Protection; + +public class PurviewDlpTokenResolversTests +{ + private const string AgentId = "aaaaaaaa-0000-4000-8000-000000000001"; + private const string TenantId = "bbbbbbbb-0000-4000-8000-000000000002"; + private const string AgenticUserId = "dddddddd-0000-4000-8000-000000000004"; + + [Fact] + public async Task AsksTheConnectionForTheAgenticUsersTokenAndEvaluatesAsMe() + { + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticUserTokenAsync(TenantId, AgentId, AgenticUserId, It.IsAny>(), It.IsAny())) + .ReturnsAsync("agentic-user-token"); + var resolver = PurviewDlpTokenResolvers.FromAgenticUser(connection.Object); + using var cancellation = new CancellationTokenSource(); + + var token = await resolver(Agent(), PurviewDlpOptions.DefaultAuthenticationScope, cancellation.Token); + + token!.AccessToken.Should().Be("agentic-user-token"); + token.UserId.Should().BeNull("the agentic user's delegated token evaluates as /me"); + connection.Verify( + provider => provider.GetAgenticUserTokenAsync( + TenantId, + AgentId, + AgenticUserId, + It.Is>(scopes => scopes.SequenceEqual(new[] { "https://graph.microsoft.com/.default" })), + cancellation.Token), + Times.Once); + } + + [Theory] + [InlineData(nameof(PurviewDlpAgentContext.TenantId))] + [InlineData(nameof(PurviewDlpAgentContext.AgentId))] + [InlineData(nameof(PurviewDlpAgentContext.AgenticUserId))] + public async Task RequiresTheAgentTenantAndAgenticUser(string missing) + { + var connection = new Mock(); + var agent = Agent(); + typeof(PurviewDlpAgentContext).GetProperty(missing)!.SetValue(agent, " "); + var resolver = PurviewDlpTokenResolvers.FromAgenticUser(connection.Object); + + var act = () => resolver(agent, PurviewDlpOptions.DefaultAuthenticationScope, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.ParamName.Should().Be(missing); + connection.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task ResolvesNoTokenWhenTheConnectionReturnsNone(string? issued) + { + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticUserTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(issued!); + + var token = await PurviewDlpTokenResolvers.FromAgenticUser(connection.Object)(Agent(), "scope", CancellationToken.None); + + token.Should().BeNull(); + } + + [Fact] + public async Task EvaluatesTheAgenticUserEndToEnd() + { + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticUserTokenAsync(TenantId, AgentId, AgenticUserId, It.IsAny>(), It.IsAny())) + .ReturnsAsync("agentic-user-token"); + var handler = new GraphHandler(_ => PurviewDlpClientTests.Graph(PurviewDlpClientTests.Clean)); + var client = new PurviewDlpClient( + new PurviewDlpOptions { Enabled = true, GraphBaseUrl = new Uri("https://graph.example.test/v1.0") }, + new HttpClient(handler)); + + var result = await client.EvaluateAsync( + PurviewDlpActivity.UploadText, + "hello", + PurviewDlpClientTests.Agent(), + PurviewDlpTokenResolvers.FromAgenticUser(connection.Object)); + + result!.Evaluated.Should().BeTrue(); + var call = handler.Calls.Single(); + call.Uri.Should().Be(new Uri("https://graph.example.test/v1.0/me/dataSecurityAndGovernance/processContent")); + call.Authorization.Should().Be("Bearer agentic-user-token"); + } + + [Fact] + public async Task PassesTheScopeToTheHostsProviderAndEvaluatesForTheGivenUser() + { + var scopes = new List(); + var resolver = PurviewDlpTokenResolvers.FromAccessTokenProvider( + (scope, _) => + { + scopes.Add(scope); + return Task.FromResult("host-token"); + }, + "user@contoso.example"); + + var token = await resolver(Agent(), "https://graph.microsoft.com/.default", CancellationToken.None); + + scopes.Should().Equal("https://graph.microsoft.com/.default"); + token!.AccessToken.Should().Be("host-token"); + token.UserId.Should().Be("user@contoso.example"); + } + + [Fact] + public async Task EvaluatesAsMeWithoutAUser() + { + var resolver = PurviewDlpTokenResolvers.FromAccessTokenProvider((_, _) => Task.FromResult("obo-token")); + + var token = await resolver(Agent(), "scope", CancellationToken.None); + + token!.UserId.Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void RejectsAnEmptyUser(string userId) + { + var create = () => PurviewDlpTokenResolvers.FromAccessTokenProvider((_, _) => Task.FromResult("token"), userId); + var token = () => new PurviewDlpToken("token", userId); + + create.Should().Throw().WithParameterName("userId"); + token.Should().Throw().WithParameterName("userId"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task ResolvesNoTokenWhenTheProviderReturnsNone(string? issued) + { + var resolver = PurviewDlpTokenResolvers.FromAccessTokenProvider((_, _) => Task.FromResult(issued!)); + + var token = await resolver(Agent(), "scope", CancellationToken.None); + + token.Should().BeNull(); + } + + [Fact] + public void RequiresAnAccessToken() + { + var create = () => new PurviewDlpToken(" "); + + create.Should().Throw().WithParameterName("accessToken"); + } + + [Fact] + public void CopiesTheContextWithASessionWithoutChangingIt() + { + var agent = Agent(); + agent.SessionId = "turn-session"; + agent.SequenceNumber = 4; + + var copy = agent.WithSession("hook-session", 9); + var kept = agent.WithSession(null, null); + + copy.Should().BeEquivalentTo(new { AgentId, TenantId, AgenticUserId, SessionId = "hook-session", SequenceNumber = 9L }); + kept.Should().BeEquivalentTo(new { SessionId = "turn-session", SequenceNumber = 4L }); + agent.SessionId.Should().Be("turn-session"); + agent.SequenceNumber.Should().Be(4); + } + + private static PurviewDlpAgentContext Agent() => new() + { + AgentId = AgentId, + TenantId = TenantId, + AgenticUserId = AgenticUserId, + }; +} diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs new file mode 100644 index 00000000..3c6e751c --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs @@ -0,0 +1,723 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using AgentHooks; +using FluentAssertions; +using Microsoft.Agents.A365.Tooling.Protection.Defender; +using Microsoft.Agents.A365.Tooling.Protection.Purview; +using Xunit; + +namespace Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests; + +/// +/// Runs the Purview interceptor under the real agent-hooks emitter (native core) against a fake Microsoft Graph +/// processContent endpoint. +/// +public class A365PurviewInterceptorTests +{ + private const string GraphBase = "https://graph.example.test/v1.0"; + private const string DefenderEndpoint = "https://prevention.example.test/v1/protection/evaluate"; + private const string AgentId = "aaaaaaaa-0000-4000-8000-000000000001"; + private const string TenantId = "bbbbbbbb-0000-4000-8000-000000000002"; + private const string BlueprintId = "cccccccc-0000-4000-8000-000000000003"; + private const string AgenticUserId = "dddddddd-0000-4000-8000-000000000004"; + private const string Payload = "BLOCK_ME"; + private const string Clean = """{"protectionScopeState":"modified","policyActions":[],"processingErrors":[]}"""; + private const string Blocked = """{"protectionScopeState":"modified","policyActions":[{"@odata.type":"#microsoft.graph.restrictAccessAction","action":"restrictAccess","restrictionAction":"block"}],"processingErrors":[]}"""; + private const string RequestBlocked = "The request was blocked by a Microsoft Purview data loss prevention policy."; + + private static readonly PurviewDlpTokenResolver Tokens = (_, _, _) => Task.FromResult(new PurviewDlpToken("graph-access-token")); + + [Fact] + public async Task AllowsACleanPromptAndSendsItAsUploadText() + { + var harness = new Harness(_ => Graph(Clean)); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "conversation:activity", "SampleAgent"); + var context = builder.Input(JsonValue.Create("Find 2 flights from Seattle to San Francisco next week")!); + + var record = await harness.Emitter.EmitUncheckedAsync(context, CancellationToken.None); + + record.Proceeds.Should().BeTrue(); + record.Verdict.Decision.Should().Be(Decision.Allow); + (record.Verdict.Warnings ?? Array.Empty()).Should().BeEmpty(); + harness.Requests.Should().ContainSingle().Which.Uri.Should().Be(new Uri(GraphBase + "/me/dataSecurityAndGovernance/processContent")); + var body = harness.Bodies.Should().ContainSingle().Subject; + var entry = Entry(body); + entry["content"]!["data"]!.GetValue().Should().Be("Find 2 flights from Seattle to San Francisco next week"); + entry["correlationId"]!.GetValue().Should().Be("conversation:activity", "the content is grouped by the agent-hooks session"); + entry["sequenceNumber"]!.GetValue().Should().Be(long.Parse(context.Json["sequence"]!.ToJsonString()), "the agent-hooks sequence orders it"); + body["contentToProcess"]!["activityMetadata"]!["activity"]!.GetValue().Should().Be("uploadText"); + body["contentToProcess"]!["protectedAppMetadata"]!["applicationLocation"]!["value"]!.GetValue().Should().Be(BlueprintId); + var evaluation = (await harness.EvaluationsAsync()).Should().ContainSingle().Subject; + evaluation.Activity.Should().Be(PurviewDlpActivity.UploadText); + evaluation.CorrelationId.Should().Be(harness.RequestIds.Single()); + } + + [Fact] + public async Task BlocksAPromptAPurviewPolicyBlocks() + { + var harness = new Harness(BlocksThePayload); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-1", "SampleAgent"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create($"Please charge my card {Payload}")!), CancellationToken.None); + + record.Proceeds.Should().BeFalse(); + record.Verdict.Decision.Should().Be(Decision.Deny); + record.Verdict.Reason.Should().Be("purview:block"); + record.Verdict.Message.Should().Be(RequestBlocked); + record.Verdict.Evidence!.Artefact.Should().Be("purview-verdict"); + record.Verdict.Evidence.VerificationPointers!["correlation"].Should().Be($"urn:a365:purview:{harness.RequestIds.Single()}"); + record.DecidedBy.Should().Be(0); + } + + [Fact] + public async Task AuditsTheReplyWithoutWaitingAndNeverBlocksIt() + { + var answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var harness = new Harness( + async (_, _) => + { + await answer.Task; + return Graph(Blocked); + }, + failClosed: true); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-2", "SampleAgent"); + + var emitting = harness.Emitter.EmitUncheckedAsync(builder.Output(JsonValue.Create($"Your card {Payload} is on file.")!), CancellationToken.None).AsTask(); + (await Task.WhenAny(emitting, Task.Delay(TimeSpan.FromSeconds(10)))).Should().BeSameAs(emitting, "the reply does not wait for Purview"); + var record = await emitting; + answer.SetResult(); + + record.Proceeds.Should().BeTrue("Purview DLP restricts prompts, so replies are audited"); + (record.Verdict.Warnings ?? Array.Empty()).Should().BeEmpty(); + var evaluation = (await harness.EvaluationsAsync()).Should().ContainSingle().Subject; + evaluation.Activity.Should().Be(PurviewDlpActivity.DownloadText); + evaluation.Evaluated.Should().BeTrue(); + evaluation.Allowed.Should().BeFalse("the callback still reports what Purview decided"); + Entry(harness.Bodies.Single())["content"]!["data"]!.GetValue().Should().Be($"Your card {Payload} is on file."); + harness.Bodies.Single()["contentToProcess"]!["activityMetadata"]!["activity"]!.GetValue().Should().Be("downloadText"); + } + + [Fact] + public async Task KeepsAuditingTheReplyAfterTheInterceptionIsCancelled() + { + var requested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var evaluated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var graph = new FakeGraph( + async (_, cancellationToken) => + { + requested.TrySetResult(); + + // Ends early only if the request's token is cancelled. + await answer.Task.WaitAsync(cancellationToken); + return Graph(Clean); + }, + new List(), + new List<(Uri Uri, string RequestId)>()); + var client = new PurviewDlpClient(new PurviewDlpOptions { Enabled = true, GraphBaseUrl = new Uri(GraphBase) }, new HttpClient(graph)); + var interceptor = new A365PurviewInterceptor(client, _ => new A365PurviewCall(Agent(), Tokens), result => evaluated.TrySetResult(result)); + var context = new AgentContextBuilder(AgentId, "agent-framework", "s-3", "SampleAgent").Output(JsonValue.Create("Here are three flights.")!); + using var interception = new CancellationTokenSource(); + + // Called directly: the emitter hands each interceptor a token of its own, which it stops using on return. + var verdict = await interceptor.InterceptAsync(context, interception.Token); + await requested.Task.WaitAsync(TimeSpan.FromSeconds(10)); + interception.Cancel(); + answer.SetResult(); + + verdict.Decision.Should().Be(Decision.Allow); + var evaluation = await evaluated.Task.WaitAsync(TimeSpan.FromSeconds(10)); + evaluation.Evaluated.Should().BeTrue("the audit is bounded by the client's deadline, not the interception's token"); + evaluation.Allowed.Should().BeTrue(); + } + + [Fact] + public async Task EnforcesTheReplyWhenConfigured() + { + var harness = new Harness(BlocksThePayload, responseMode: PurviewDlpResponseMode.Enforce); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-4", "SampleAgent"); + + var clean = await harness.Emitter.EmitUncheckedAsync(builder.Output(JsonValue.Create("Here are three flights.")!), CancellationToken.None); + var blocked = await harness.Emitter.EmitUncheckedAsync(builder.Output(JsonValue.Create($"Your card {Payload} is on file.")!), CancellationToken.None); + + clean.Proceeds.Should().BeTrue(); + blocked.Proceeds.Should().BeFalse(); + blocked.Verdict.Reason.Should().Be("purview:block"); + blocked.Verdict.Message.Should().Be("The response was blocked by a Microsoft Purview data loss prevention policy."); + harness.Bodies.Should().HaveCount(2).And.OnlyContain(body => body["contentToProcess"]!["activityMetadata"]!["activity"]!.GetValue() == "downloadText"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenNoIdentityIsResolved(bool failClosed) + { + var harness = new Harness(_ => Graph(Clean), failClosed: failClosed, resolveCall: _ => null); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-5"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed); + harness.Bodies.Should().BeEmpty(); + (await harness.EvaluationsAsync()).Should().ContainSingle().Which.Error.Should().Be("no agent identity was resolved"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenResolvingTheCallFails(bool failClosed) + { + var harness = new Harness(_ => Graph(Clean), failClosed: failClosed, resolveCall: _ => throw new InvalidOperationException("no turn is available")); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-6"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed); + harness.Bodies.Should().BeEmpty(); + } + + [Fact] + public async Task RecordsOnlyTheExceptionTypeWhenTheEvaluationFails() + { + const string Secret = "client_secret=do-not-leak"; + var harness = new Harness(_ => Graph(Clean), resolveCall: _ => throw new InvalidOperationException(Secret)); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-7"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed: false); + (await harness.EvaluationsAsync()).Should().ContainSingle().Which.Error.Should().Be("evaluation failed (InvalidOperationException)"); + record.Verdict.Warnings.Should().OnlyContain(warning => warning.Message == null || !warning.Message.Contains(Secret)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenTheTokenResolverFails(bool failClosed) + { + var harness = new Harness( + _ => Graph(Clean), + failClosed: failClosed, + tokenResolver: (_, _, _) => throw new InvalidOperationException("no credential")); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-8"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed); + harness.Bodies.Should().BeEmpty(); + (await harness.EvaluationsAsync()).Should().ContainSingle().Which.Error.Should().Be("entra token unavailable (InvalidOperationException)"); + } + + [Fact] + public async Task ReportsAReplyAuditThatCouldNotStartWithoutBlockingTheReply() + { + var calls = 0; + var harness = new Harness( + _ => Graph(Clean), + failClosed: true, + resolveCall: _ => ++calls == 1 ? throw new InvalidOperationException("no turn is available") : (A365PurviewCall?)null); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-9"); + + var failed = await harness.Emitter.EmitUncheckedAsync(builder.Output(JsonValue.Create("Here are three flights.")!), CancellationToken.None); + var unresolved = await harness.Emitter.EmitUncheckedAsync(builder.Output(JsonValue.Create("Anything else?")!), CancellationToken.None); + + failed.Proceeds.Should().BeTrue("a reply audited in the background is never blocked, whatever the fail mode"); + unresolved.Proceeds.Should().BeTrue(); + harness.Bodies.Should().BeEmpty(); + (await harness.EvaluationsAsync(2)).Select(evaluation => evaluation.Error) + .Should().BeEquivalentTo("evaluation failed (InvalidOperationException)", "no agent identity was resolved"); + } + + [Fact] + public async Task DoesNotCallPurviewAtOtherPoints() + { + var resolved = 0; + var harness = new Harness(_ => Graph(Blocked), resolveCall: _ => + { + resolved++; + return new A365PurviewCall(Agent(), Tokens); + }); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-10"); + + var records = new[] + { + await harness.Emitter.EmitUncheckedAsync(builder.AgentStartup(new[] { "SearchFlights" }), CancellationToken.None), + await harness.Emitter.EmitUncheckedAsync( + builder.PreModelCall("gpt-4o", new JsonArray(new JsonObject { ["role"] = "user", ["content"] = Payload }), new JsonArray(), null), + CancellationToken.None), + await harness.Emitter.EmitUncheckedAsync( + builder.PreToolCall("call-1", "SearchFlights", new JsonObject { ["query"] = Payload }), + CancellationToken.None), + await harness.Emitter.EmitUncheckedAsync( + builder.PostToolCall("call-1", "SearchFlights", new JsonObject { ["query"] = "SEA" }, JsonValue.Create(Payload)!), + CancellationToken.None), + await harness.Emitter.EmitUncheckedAsync(builder.AgentShutdown("completed"), CancellationToken.None), + }; + + records.Should().OnlyContain(record => record.Proceeds); + harness.Bodies.Should().BeEmpty(); + resolved.Should().Be(0, "the call is resolved only for the points Purview evaluates"); + } + + [Fact] + public async Task DoesNotCallPurviewForContentWithoutText() + { + var resolved = 0; + var harness = new Harness(_ => Graph(Blocked), resolveCall: _ => + { + resolved++; + return new A365PurviewCall(Agent(), Tokens); + }); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-11"); + + var blank = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create(" \n ")!), CancellationToken.None); + var empty = await harness.Emitter.EmitUncheckedAsync(builder.Input(new JsonObject { ["attachments"] = new JsonArray() }), CancellationToken.None); + + blank.Proceeds.Should().BeTrue(); + empty.Proceeds.Should().BeTrue(); + harness.Bodies.Should().BeEmpty(); + resolved.Should().Be(0); + } + + [Fact] + public async Task DoesNothingWhenDisabled() + { + var resolved = 0; + var harness = new Harness(_ => Graph(Blocked), enabled: false, resolveCall: _ => + { + resolved++; + return new A365PurviewCall(Agent(), Tokens); + }); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-12"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create(Payload)!), CancellationToken.None); + + record.Proceeds.Should().BeTrue(); + harness.Bodies.Should().BeEmpty(); + resolved.Should().Be(0); + } + + [Fact] + public async Task SendsEveryStringAndNumberOfStructuredContent() + { + var harness = new Harness(_ => Graph(Clean)); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-13"); + var content = new JsonArray( + new JsonObject { ["type"] = "text", ["text"] = "Book the hotel" }, + new JsonObject { ["type"] = "image_url", ["image_url"] = new JsonObject { ["url"] = "https://images.example.test/a.png", ["detail"] = null } }, + new JsonObject { ["card"] = 4111111111111111, ["confirmed"] = true }); + + await harness.Emitter.EmitUncheckedAsync(builder.Input(content), CancellationToken.None); + + Entry(harness.Bodies.Single())["content"]!["data"]!.GetValue() + .Should().Be("text\nBook the hotel\nimage_url\nhttps://images.example.test/a.png\n4111111111111111"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenPurviewAllowsATruncatedCopy(bool failClosed) + { + var harness = new Harness(BlocksThePayload, failClosed: failClosed, maxContentCharacters: 100); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-14"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create(new string('a', 100) + Payload)!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed); + record.Verdict.Warnings.Should().Contain(warning => warning.Reason == "purview:unverified" && warning.Message == PurviewDlpClient.TruncatedContentError); + } + + [Fact] + public async Task BlocksWhenPurviewBlocksATruncatedCopy() + { + var harness = new Harness(BlocksThePayload, maxContentCharacters: 100); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-15"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create(Payload + new string('a', 200))!), CancellationToken.None); + + record.Proceeds.Should().BeFalse(); + record.Verdict.Reason.Should().Be("purview:block"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task KeepsTheVerdictWhenTheEvaluationCallbackThrows(bool block) + { + var harness = new Harness(_ => Graph(block ? Blocked : Clean), onEvaluated: _ => throw new InvalidOperationException("telemetry is unavailable")); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-16"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + record.Proceeds.Should().Be(!block); + if (block) + { + record.Verdict.Reason.Should().Be("purview:block", "a failing callback is not a host error"); + } + } + + [Fact] + public async Task DoesNotLetASlowEvaluationCallbackDecideTheVerdict() + { + using var release = new ManualResetEventSlim(); + var callbackDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var harness = new Harness( + _ => Graph(Clean), + timeout: TimeSpan.FromMilliseconds(500), + onEvaluated: _ => + { + release.Wait(TimeSpan.FromSeconds(30)); + callbackDone.TrySetResult(); + }, + tokenResolver: async (_, _, _) => + { + // Asynchronous like a real call, so the emitter is already timing the interceptor when the callback runs. + await Task.Delay(10).ConfigureAwait(false); + return new PurviewDlpToken("graph-access-token"); + }); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-17"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + release.Set(); + await callbackDone.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + record.Proceeds.Should().BeTrue("the callback runs after the verdict, outside the emitter's interceptor timeout"); + record.Verdict.Decision.Should().Be(Decision.Allow); + } + + [Fact] + public async Task DoesNotChangeTheAgentContextTheFactoryReturns() + { + var agent = Agent(); + agent.SessionId = "turn-session"; + var harness = new Harness(_ => Graph(Clean), resolveCall: _ => new A365PurviewCall(agent, Tokens)); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "hook-session"); + + await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("one")!), CancellationToken.None); + await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("two")!), CancellationToken.None); + + agent.SessionId.Should().Be("turn-session"); + agent.SequenceNumber.Should().BeNull(); + harness.Bodies.Select(body => Entry(body)["correlationId"]!.GetValue()).Should().Equal("hook-session", "hook-session"); + var sequences = harness.Bodies.Select(body => Entry(body)["sequenceNumber"]!.GetValue()).ToList(); + sequences[1].Should().BeGreaterThan(sequences[0]); + } + + [Fact] + public async Task AppliesTheFailModeBeforeTheInterceptorTimeout() + { + var options = new PurviewDlpOptions { Enabled = true, GraphBaseUrl = new Uri(GraphBase), Timeout = TimeSpan.FromSeconds(1) }; + var client = new PurviewDlpClient(options, new HttpClient(new HangingEndpoint())); + PurviewDlpTokenResolver slowToken = async (_, _, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(900), cancellationToken); + return new PurviewDlpToken("graph-access-token"); + }; + + // 0.9 s for the token plus a fresh 1 s for the request would outlast this 1.6 s interceptor timeout. + var emitter = A365AgentHooks.CreateProtectionEmitter(TimeSpan.FromMilliseconds(1600), purview: options) + .AddA365Purview(new A365PurviewInterceptor(client, _ => new A365PurviewCall(Agent(), slowToken))); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-18"); + + var record = await emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + record.Proceeds.Should().BeTrue("Purview is configured to fail open"); + record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "purview:unverified"); + } + + [Fact] + public async Task SizesTheInterceptorTimeoutForTheSlowerProvider() + { + var defender = new DefenderRtpOptions { Enabled = false, Endpoint = new Uri(DefenderEndpoint), Timeout = TimeSpan.FromMilliseconds(100) }; + var purview = new PurviewDlpOptions { Enabled = true, GraphBaseUrl = new Uri(GraphBase), Timeout = TimeSpan.FromMilliseconds(2500) }; + var client = new PurviewDlpClient(purview, new HttpClient(new HangingEndpoint())); + + // Sized from Defender alone, the emitter would give up after 2.1 s, before Purview's 2.5 s deadline. + var emitter = A365AgentHooks.CreateProtectionEmitter(defender: defender, purview: purview) + .AddA365Purview(new A365PurviewInterceptor(client, _ => new A365PurviewCall(Agent(), Tokens))); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-19"); + + var record = await emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + record.Proceeds.Should().BeTrue("Purview's own deadline applies its fail-open mode before the interceptor timeout"); + record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "purview:unverified" && warning.Message == "request timeout"); + } + + [Fact] + public async Task ComposesDefenderAndPurviewOnOneEmitter() + { + var endpoints = new RoutingEndpoint( + defender: body => body["interception_point"]!.GetValue() == "pre_tool_call" && body.ToJsonString().Contains("malicious.example.test", StringComparison.Ordinal) + ? Json(new { decision = "deny", reason = "prevention_blocked", message = "Known malicious URL." }) + : Json(new { decision = "allow" }), + graph: body => Graph(body.ToJsonString().Contains(Payload, StringComparison.Ordinal) ? Blocked : Clean)); + var defenderOptions = new DefenderRtpOptions { Enabled = true, Endpoint = new Uri(DefenderEndpoint) }; + var purviewOptions = new PurviewDlpOptions { Enabled = true, GraphBaseUrl = new Uri(GraphBase) }; + var defender = new DefenderRtpClient(defenderOptions, new HttpClient(endpoints)); + var purview = new PurviewDlpClient(purviewOptions, new HttpClient(endpoints)); + var defenderAgent = new DefenderRtpAgentContext { AgentId = AgentId, TenantId = TenantId }; + var emitter = A365AgentHooks.CreateProtectionEmitter(defender: defenderOptions, purview: purviewOptions) + .AddA365Defender(new A365DefenderInterceptor(defender, _ => new A365DefenderCall(defenderAgent, (_, _, _, _) => Task.FromResult("defender-token")))) + .AddA365Purview(new A365PurviewInterceptor(purview, _ => new A365PurviewCall(Agent(), Tokens))); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-20", "SampleAgent"); + + var clean = await emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("Find 2 flights from Seattle to San Francisco")!), CancellationToken.None); + var card = await emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create($"Please charge my card {Payload}")!), CancellationToken.None); + var tool = await emitter.EmitUncheckedAsync( + builder.PreToolCall("call-1", "FetchTravelAdvisory", new JsonObject { ["url"] = "https://malicious.example.test" }), + CancellationToken.None); + + clean.Proceeds.Should().BeTrue("both providers allow a clean turn"); + card.Proceeds.Should().BeFalse(); + card.Verdict.Reason.Should().Be("purview:block"); + card.DecidedBy.Should().Be(1, "Purview, registered second, decided"); + tool.Proceeds.Should().BeFalse(); + tool.Verdict.Reason.Should().Be("defender:block:prevention_blocked"); + endpoints.DefenderPoints.Should().Equal("input", "input", "pre_tool_call"); + endpoints.GraphActivities.Should().Equal("uploadText", "uploadText"); + } + + [Fact] + public void MapsAPurviewResultToAnAgentHooksVerdict() + { + var blocked = A365PurviewInterceptor.ToVerdict(new PurviewDlpEvaluationResult + { + Allowed = false, + Evaluated = true, + CorrelationId = "cid/1", + BlockReason = RequestBlocked, + Decision = new PurviewDlpDecision { BlockAction = true, RestrictionAction = "block", ActionCount = 1 }, + }); + var unverifiedOpen = A365PurviewInterceptor.ToVerdict(new PurviewDlpEvaluationResult { Allowed = true, Evaluated = false, Error = "http 403" }); + var unverifiedClosed = A365PurviewInterceptor.ToVerdict(new PurviewDlpEvaluationResult { Allowed = false, Evaluated = false, Error = "http 503" }); + + blocked.Decision.Should().Be(Decision.Deny); + blocked.Reason.Should().Be("purview:block"); + blocked.Evidence!.VerificationPointers!["correlation"].Should().Be("urn:a365:purview:cid%2F1"); + unverifiedOpen.Decision.Should().Be(Decision.Allow); + unverifiedOpen.Warnings.Should().ContainSingle(warning => warning.Reason == "purview:unverified" && warning.Message == "http 403"); + unverifiedClosed.Decision.Should().Be(Decision.Deny); + unverifiedClosed.Reason.Should().Be("runtime_error:purview_unverified"); + unverifiedClosed.Message.Should().Be("Data loss prevention validation is unavailable and this agent is configured to fail closed."); + } + + [Fact] + public void RegistersOnlyAnInterceptor() + { + var emitter = A365AgentHooks.CreateProtectionEmitter(); + var register = () => emitter.AddA365Purview(null!); + var withoutEmitter = () => A365AgentHooks.AddA365Purview(null!, new A365PurviewInterceptor(new PurviewDlpClient(new PurviewDlpOptions()), _ => null)); + + register.Should().Throw().WithParameterName("interceptor"); + withoutEmitter.Should().Throw().WithParameterName("emitter"); + } + + private static JsonObject Entry(JsonObject body) => body["contentToProcess"]!["contentEntries"]![0]!.AsObject(); + + private static PurviewDlpAgentContext Agent() => new() + { + AgentId = AgentId, + TenantId = TenantId, + AgenticUserId = AgenticUserId, + BlueprintId = BlueprintId, + AgentName = "SampleAgent", + }; + + /// A fake Graph that blocks only text containing . + private static HttpResponseMessage BlocksThePayload(JsonObject body) => + Graph(Entry(body)["content"]!["data"]!.GetValue().Contains(Payload, StringComparison.Ordinal) ? Blocked : Clean); + + private static void AssertFollowsTheFailMode(InterceptionRecord record, bool failClosed) + { + record.Proceeds.Should().Be(!failClosed); + if (failClosed) + { + record.Verdict.Reason.Should().Be("runtime_error:purview_unverified", "a failure is not a host error"); + } + else + { + record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "purview:unverified"); + } + } + + private static HttpResponseMessage Graph(string body, HttpStatusCode status = HttpStatusCode.OK) => new(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + private static HttpResponseMessage Json(object body) => new(HttpStatusCode.OK) + { + Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"), + }; + + private sealed class Harness + { + private readonly List _evaluations = new(); + private readonly SemaphoreSlim _evaluated = new(0); + + public Harness( + Func respond, + bool failClosed = false, + PurviewDlpResponseMode responseMode = PurviewDlpResponseMode.Audit, + Action? onEvaluated = null, + Func? resolveCall = null, + PurviewDlpTokenResolver? tokenResolver = null, + bool enabled = true, + TimeSpan? timeout = null, + int? maxContentCharacters = null) + : this((body, _) => Task.FromResult(respond(body)), failClosed, responseMode, onEvaluated, resolveCall, tokenResolver, enabled, timeout, maxContentCharacters) + { + } + + public Harness( + Func> respond, + bool failClosed = false, + PurviewDlpResponseMode responseMode = PurviewDlpResponseMode.Audit, + Action? onEvaluated = null, + Func? resolveCall = null, + PurviewDlpTokenResolver? tokenResolver = null, + bool enabled = true, + TimeSpan? timeout = null, + int? maxContentCharacters = null) + { + var handler = new FakeGraph(respond, Bodies, Requests); + var options = new PurviewDlpOptions + { + Enabled = enabled, + GraphBaseUrl = new Uri(GraphBase), + FailClosed = failClosed, + ResponseMode = responseMode, + }; + if (timeout is { } deadline) + { + options.Timeout = deadline; + } + + if (maxContentCharacters is { } maximum) + { + options.MaxContentCharacters = maximum; + } + + var client = new PurviewDlpClient(options, new HttpClient(handler)); + var tokens = tokenResolver ?? Tokens; + Emitter = A365AgentHooks.CreateProtectionEmitter(purview: options) + .AddA365Purview(new A365PurviewInterceptor( + client, + resolveCall ?? (_ => new A365PurviewCall(Agent(), tokens)), + onEvaluated ?? Record)); + } + + public InterceptionEmitter Emitter { get; } + + public List Bodies { get; } = new(); + + public List<(Uri Uri, string RequestId)> Requests { get; } = new(); + + public IEnumerable RequestIds => Requests.Select(request => request.RequestId); + + /// Waits for the evaluations the callback receives, which runs on the thread pool after the verdict. + public async Task> EvaluationsAsync(int count = 1) + { + for (var received = 0; received < count; received++) + { + (await _evaluated.WaitAsync(TimeSpan.FromSeconds(10))).Should().BeTrue("the callback runs soon after the verdict"); + } + + lock (_evaluations) + { + return _evaluations.ToList(); + } + } + + private void Record(PurviewDlpEvaluationResult result) + { + lock (_evaluations) + { + _evaluations.Add(result); + } + + _evaluated.Release(); + } + } + + /// An endpoint that never answers, so only a deadline ends the request. + private sealed class HangingEndpoint : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK); + } + } + + private sealed class FakeGraph : HttpMessageHandler + { + private readonly Func> _respond; + private readonly List _bodies; + private readonly List<(Uri Uri, string RequestId)> _requests; + + public FakeGraph( + Func> respond, + List bodies, + List<(Uri Uri, string RequestId)> requests) + { + _respond = respond; + _bodies = bodies; + _requests = requests; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(cancellationToken))!.AsObject(); + lock (_bodies) + { + _bodies.Add(body); + _requests.Add((request.RequestUri!, request.Headers.GetValues(PurviewDlpClient.ClientRequestIdHeader).Single())); + } + + return await _respond(body, cancellationToken); + } + } + + /// Answers for the fake Defender endpoint and the fake Graph, recording what each received. + private sealed class RoutingEndpoint : HttpMessageHandler + { + private readonly Func _defender; + private readonly Func _graph; + + public RoutingEndpoint(Func defender, Func graph) + { + _defender = defender; + _graph = graph; + } + + public List DefenderPoints { get; } = new(); + + public List GraphActivities { get; } = new(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(cancellationToken))!.AsObject(); + if (request.RequestUri == new Uri(DefenderEndpoint)) + { + lock (DefenderPoints) + { + DefenderPoints.Add(body["interception_point"]!.GetValue()); + } + + return _defender(body); + } + + lock (GraphActivities) + { + GraphActivities.Add(body["contentToProcess"]!["activityMetadata"]!["activity"]!.GetValue()); + } + + return _graph(body); + } + } +} diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs new file mode 100644 index 00000000..1305590e --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs @@ -0,0 +1,729 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling.Protection.Purview +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Text; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Text.RegularExpressions; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Client for Microsoft Purview data loss prevention (DLP) and audit through the Microsoft Graph + /// processContent API. + /// + /// + /// Each evaluation sends one text to POST {GraphBaseUrl}/me/dataSecurityAndGovernance/processContent (or + /// /users/{id}/... when the token resolver names a user). Purview applies the DLP policies scoped to the + /// agent's application and writes the audit record. A policy action whose restrictionAction is + /// block blocks; other actions allow. Each call carries a unique client-request-id, returned as + /// . + /// + public sealed class PurviewDlpClient + { + /// The header Graph logs each call under. + public const string ClientRequestIdHeader = "client-request-id"; + + /// + /// The of a result whose text was longer than + /// and that Purview allowed: Purview evaluated a truncated + /// copy, so the result follows . + /// + public const string TruncatedContentError = "content exceeded MaxContentCharacters; Purview evaluated a truncated copy"; + + /// The agent name used when is not set. + public const string DefaultAgentName = "agent365-agent"; + + private const string ProcessContentPath = "/dataSecurityAndGovernance/processContent"; + private const string AppVersion = "1.0"; + private const string RequestBlocked = "The request was blocked by a Microsoft Purview data loss prevention policy."; + private const string ResponseBlocked = "The response was blocked by a Microsoft Purview data loss prevention policy."; + private const string UnavailableFailClosed = + "Data loss prevention validation is unavailable and this agent is configured to fail closed."; + private const string TruncatedFailClosed = + "Microsoft Purview could not evaluate all of the content and this agent is configured to fail closed."; + + // Codes and error types Graph reports in processingErrors are kept only when they look like identifiers, so no + // message text from the response reaches the result. + private static readonly Regex DiagnosticToken = new("^[A-Za-z][A-Za-z0-9_.-]{0,63}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + // Used when the caller passes no HttpClient. Pooled connections are recycled so DNS changes are picked up, and + // redirects are never followed: a 307 or 308 would replay the token and the agent's content to another host. + internal static readonly SocketsHttpHandler SharedHandler = new() + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + AllowAutoRedirect = false, + }; + + internal static readonly HttpClient SharedHttpClient = new(SharedHandler); + + private readonly HttpClient _httpClient; + private readonly Func _idFactory; + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _sequences = new(StringComparer.Ordinal); + + // The first sequence number a session the client does not track gets: above every number given to a session it + // dropped, so a session that comes back keeps increasing. + private long _sequenceFloor; + + /// Initializes a new instance of the class. + /// The Purview configuration, for example . + /// + /// The HTTP client, for example from IHttpClientFactory; defaults to a shared client that does not + /// follow redirects. A client passed here must not follow them either: a redirected request is treated as a + /// failure, since the token and content have been sent to another host. + /// + /// Creates request and content ids (tests). + /// The clock (tests). + public PurviewDlpClient( + PurviewDlpOptions options, + HttpClient? httpClient = null, + Func? idFactory = null, + TimeProvider? timeProvider = null) + { + Options = options ?? throw new ArgumentNullException(nameof(options)); + options.Validate(); + _httpClient = httpClient ?? SharedHttpClient; + _idFactory = idFactory ?? Guid.NewGuid; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// Gets the configuration this client uses. + public PurviewDlpOptions Options { get; } + + /// How many sessions' sequences are tracked; settable for tests, so eviction needs few sessions. + internal int MaxTrackedSessions { get; init; } = 1000; + + /// + /// Evaluates a text with Purview: for content entering the agent, + /// such as the user's prompt, or for its reply. + /// + /// + /// One deadline, , covers token acquisition and the request. When no + /// verdict is obtained (token, transport or HTTP failure, an unexpected response, or processing errors that + /// Graph reports inline) the result follows . A text longer than + /// is cut and sent with isTruncated; Purview's block + /// stands, but its allow follows the fail mode (). A block + /// also stands when Graph reports processing errors, or actions of another shape, with it. + /// + /// The activity the text belongs to. + /// The text to evaluate. + /// + /// The agent and turn: and one of + /// , or + /// are required. + /// + /// Resolves the Microsoft Graph token, for example . + /// Cancels the evaluation. + /// The result, or null (and no call) when Purview DLP is disabled or the text is empty. + /// The agent context has no session id or application id. + public async Task EvaluateAsync( + PurviewDlpActivity activity, + string? text, + PurviewDlpAgentContext agent, + PurviewDlpTokenResolver tokenResolver, + CancellationToken cancellationToken = default) + { + if (!Options.Enabled) + { + return null; + } + + if (agent == null) + { + throw new ArgumentNullException(nameof(agent)); + } + + if (tokenResolver == null) + { + throw new ArgumentNullException(nameof(tokenResolver)); + } + + if (activity is not (PurviewDlpActivity.UploadText or PurviewDlpActivity.DownloadText)) + { + throw new ArgumentOutOfRangeException(nameof(activity)); + } + + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var sessionId = FirstNonEmpty(agent.SessionId) + ?? throw new ArgumentException("The agent context's SessionId is required.", nameof(agent)); + var applicationId = FirstNonEmpty(agent.ApplicationId, agent.BlueprintId, agent.AgentId) + ?? throw new ArgumentException("The agent context's ApplicationId, BlueprintId or AgentId is required.", nameof(agent)); + var started = _timeProvider.GetTimestamp(); + var correlationId = _idFactory().ToString(); + var sequence = agent.SequenceNumber is long given && given >= 0 ? given : NextSequence(sessionId); + + // Text over the limit is cut, never split inside a surrogate pair, and sent with isTruncated set. + var maxCharacters = Options.MaxContentCharacters; + var truncated = text.Length > maxCharacters; + var data = ReplaceLoneSurrogates(truncated ? text.Substring(0, KeepSurrogatePairs(text, maxCharacters)) : text); + + // One deadline covers token acquisition and the request, so the fail mode applies within Options.Timeout, + // before an agent-hooks interceptor timeout would. + using var timeout = new CancellationTokenSource(Options.Timeout, _timeProvider); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + + PurviewDlpToken? token; + try + { + token = await ResolveTokenAsync(agent, tokenResolver, Options.AuthenticationScope, deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Failure(activity, correlationId, sessionId, "entra token timeout", null, started); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + // Only the type: the exception's message can carry credentials. + return Failure(activity, correlationId, sessionId, $"entra token unavailable ({ex.GetType().Name})", null, started); + } + + if (token == null) + { + return Failure(activity, correlationId, sessionId, "entra token unavailable", null, started); + } + + // A token that arrives as the deadline passes is not used: the evaluation is out of time, and a handler that + // ignores cancellation would otherwise still send the content. + if (deadline.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + return Failure(activity, correlationId, sessionId, "entra token timeout", null, started); + } + + var body = BuildBody(activity, data, truncated, agent, applicationId, sessionId, sequence); + return await PostAsync(activity, body, token, correlationId, sessionId, truncated, started, deadline.Token, cancellationToken).ConfigureAwait(false); + } + + /// + /// A result for an evaluation that could not be made (for example, no agent identity): it follows + /// , like a transport failure. + /// + /// The activity. + /// Why no verdict was obtained. + /// The session id, when known. + /// The HTTP status, when a response was received. + /// Time spent before the failure. + /// The not-evaluated result. + public PurviewDlpEvaluationResult Unavailable( + PurviewDlpActivity activity, + string error, + string? sessionId = null, + int? httpStatus = null, + TimeSpan latency = default) => new() + { + Allowed = !Options.FailClosed, + Evaluated = false, + Activity = activity, + CorrelationId = _idFactory().ToString(), + SessionId = sessionId, + HttpStatus = httpStatus, + Latency = latency, + Error = error, + BlockReason = Options.FailClosed ? UnavailableFailClosed : null, + }; + + /// + /// Calls the resolver within the deadline, which also bounds a resolver that ignores cancellation. It starts on + /// the thread pool, so a resolver that blocks before it returns its task is bounded too, and the caller, such as + /// an agent-hooks emitter timing the interceptor, is never held by it. + /// + private static async Task ResolveTokenAsync( + PurviewDlpAgentContext agent, + PurviewDlpTokenResolver tokenResolver, + string scope, + CancellationToken deadline) + { + var resolving = Task.Run( + () => tokenResolver(agent, scope, deadline) ?? throw new InvalidOperationException("The token resolver returned no task."), + CancellationToken.None); + try + { + return await resolving.WaitAsync(deadline).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + ObserveFailure(resolving); + throw; + } + } + + /// Observes the failure of a task no caller awaits, so it is not reported as unobserved. + private static void ObserveFailure(Task task) => + _ = task.ContinueWith( + static completed => { _ = completed.Exception; }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + /// + /// The processContent request: one conversation entry with the text, the agent, the session and its + /// sequence, and the application the DLP policies are scoped to. + /// + private JsonObject BuildBody( + PurviewDlpActivity activity, + string data, + bool truncated, + PurviewDlpAgentContext agent, + string applicationId, + string sessionId, + long sequence) + { + // Graph reports an entry without a name as a permanent BadRequest in processingErrors, with HTTP 200 and no + // policy actions, so the name is never empty. + var agentName = ReplaceLoneSurrogates(FirstNonEmpty(agent.AgentName) ?? DefaultAgentName); + var activityName = ActivityName(activity); + var timestamp = _timeProvider.GetUtcNow().UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + var agentInfo = new JsonObject { ["@odata.type"] = "microsoft.graph.aiAgentInfo" }; + if (FirstNonEmpty(agent.BlueprintId) is { } blueprintId) + { + agentInfo["blueprintId"] = ReplaceLoneSurrogates(blueprintId); + } + + agentInfo["identifier"] = ReplaceLoneSurrogates(FirstNonEmpty(agent.AgentId) ?? applicationId); + agentInfo["name"] = agentName; + agentInfo["version"] = AppVersion; + + return new JsonObject + { + ["contentToProcess"] = new JsonObject + { + ["contentEntries"] = new JsonArray(new JsonObject + { + ["@odata.type"] = "microsoft.graph.processConversationMetadata", + ["identifier"] = _idFactory().ToString(), + ["content"] = new JsonObject + { + ["@odata.type"] = "microsoft.graph.textContent", + ["data"] = data, + }, + ["agents"] = new JsonArray(agentInfo), + ["name"] = $"{agentName} {activityName}", + ["correlationId"] = ReplaceLoneSurrogates(sessionId), + ["sequenceNumber"] = sequence, + ["isTruncated"] = truncated, + ["createdDateTime"] = timestamp, + ["modifiedDateTime"] = timestamp, + ["contentCategory"] = "ai", + }), + ["activityMetadata"] = new JsonObject { ["activity"] = activityName }, + ["integratedAppMetadata"] = new JsonObject { ["name"] = agentName, ["version"] = AppVersion }, + ["protectedAppMetadata"] = new JsonObject + { + ["name"] = agentName, + ["version"] = AppVersion, + ["applicationLocation"] = new JsonObject + { + ["@odata.type"] = "microsoft.graph.policyLocationApplication", + ["value"] = ReplaceLoneSurrogates(applicationId), + }, + }, + }, + }; + } + + /// Posts the request within , the evaluation's one deadline, and reads the verdict. + private async Task PostAsync( + PurviewDlpActivity activity, + JsonObject body, + PurviewDlpToken token, + string correlationId, + string sessionId, + bool truncated, + long started, + CancellationToken deadline, + CancellationToken cancellationToken) + { + // Options is mutable: read the base URL once, and never send the token or content to one that is not HTTPS. + var baseUrl = Options.GraphBaseUrl; + if (!PurviewDlpOptions.IsHttpsBaseUrl(baseUrl)) + { + return Failure(activity, correlationId, sessionId, "graph base URL is not an absolute https URL", null, started); + } + + var user = token.UserId == null ? "/me" : "/users/" + Uri.EscapeDataString(token.UserId); + var url = new Uri(baseUrl.AbsoluteUri.TrimEnd('/') + user + ProcessContentPath); + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + request.Headers.TryAddWithoutValidation(ClientRequestIdHeader, correlationId); + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request, deadline).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Failure(activity, correlationId, sessionId, "request timeout", null, started); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + // Any transport failure, including resilience handlers' exceptions, is no verdict and follows the + // fail mode; only the exception's type is kept. + return Failure(activity, correlationId, sessionId, $"request failed ({ex.GetType().Name})", null, started); + } + + using (response) + { + var status = (int)response.StatusCode; + + // A client that follows redirects has sent the token and content to another host; what came back is no + // verdict. + if (request.RequestUri != url) + { + return Failure(activity, correlationId, sessionId, "request was redirected", status, started); + } + + // Accepted without an inline verdict: Purview processes the content and applies no restriction to it. + if (status is 202 or 204) + { + return Verdict(activity, correlationId, sessionId, new PurviewDlpDecision(), null, status, truncated, started); + } + + // The body of an error is never read into the result: it can echo the request. + if (!response.IsSuccessStatusCode) + { + return Failure(activity, correlationId, sessionId, $"http {status}", status, started); + } + + string content; + try + { + content = await response.Content.ReadAsStringAsync(deadline).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Failure(activity, correlationId, sessionId, "request timeout", status, started); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + return Failure(activity, correlationId, sessionId, $"response body could not be read ({ex.GetType().Name})", status, started); + } + + JsonNode? payload; + try + { + payload = JsonNode.Parse(content); + } + catch (JsonException) + { + return Failure(activity, correlationId, sessionId, "non-JSON response", status, started); + } + + return Interpret(activity, payload, correlationId, sessionId, truncated, status, started); + } + } + + /// + /// Reads Purview's verdict, checking the shape of each node. A block action blocks, even when processing errors or + /// actions of another shape come with it. Otherwise processing errors, or a response of another shape, are no + /// verdict: Graph always returns policyActions, empty when nothing applies, so a body without it, such + /// as another service's, is not an allow. + /// + private PurviewDlpEvaluationResult Interpret( + PurviewDlpActivity activity, + JsonNode? payload, + string correlationId, + string sessionId, + bool truncated, + int status, + long started) + { + if (payload is not JsonObject response) + { + return Failure(activity, correlationId, sessionId, "response contained no verdict", status, started); + } + + JsonNode? policyActions; + JsonNode? processingErrors; + string? scopeState; + try + { + policyActions = response["policyActions"]; + processingErrors = response["processingErrors"]; + scopeState = ReadString(response["protectionScopeState"]); + } + catch (ArgumentException) + { + // JsonObject builds its dictionary when a member is first read, so a property that appears twice + // surfaces here: the response is ambiguous, not a verdict. + return Failure(activity, correlationId, sessionId, "response contained duplicate properties", status, started); + } + + if (policyActions is not JsonArray actions) + { + var error = policyActions == null ? "response contained no verdict" : "response contained unexpected policyActions"; + return Failure(activity, correlationId, sessionId, error, status, started, scopeState: scopeState); + } + + var decision = ReadDecision(actions, out var malformed); + if (!decision.BlockAction) + { + if (malformed) + { + return Failure(activity, correlationId, sessionId, "response contained unexpected policyActions", status, started, decision, scopeState); + } + + switch (processingErrors) + { + case null: + break; + case JsonArray errors when errors.Count == 0: + break; + case JsonArray errors: + // A permanent BadRequest is reported here with HTTP 200 and no policy actions: the content was + // not evaluated, so it is not an allow. + return Failure(activity, correlationId, sessionId, ProcessingErrors(errors), status, started, decision, scopeState); + default: + return Failure(activity, correlationId, sessionId, "response contained unexpected processingErrors", status, started, decision, scopeState); + } + } + + return Verdict(activity, correlationId, sessionId, decision, scopeState, status, truncated, started); + } + + /// + /// The policy actions. Any action restricted with block blocks, whatever shape the other actions have; + /// is set when an action is not an object whose restrictionAction, when + /// present, is a string. + /// + private static PurviewDlpDecision ReadDecision(JsonArray actions, out bool malformed) + { + malformed = false; + string? blocking = null; + foreach (var entry in actions) + { + if (!TryReadRestriction(entry, out var restriction)) + { + malformed = true; + } + else if (blocking == null && restriction != null && restriction.Equals("block", StringComparison.OrdinalIgnoreCase)) + { + blocking = restriction; + } + } + + return new PurviewDlpDecision + { + BlockAction = blocking != null, + RestrictionAction = blocking, + ActionCount = actions.Count, + }; + } + + /// + /// An action's restrictionAction, and whether the action has the expected shape: an object whose + /// restrictionAction, when present, is a string, and whose properties each appear once. + /// + private static bool TryReadRestriction(JsonNode? entry, out string? restriction) + { + restriction = null; + if (entry is not JsonObject action) + { + return false; + } + + try + { + var value = action["restrictionAction"]; + restriction = ReadString(value); + return value == null || restriction != null; + } + catch (ArgumentException) + { + return false; + } + } + + /// The processing errors as a count, with the codes and error types that look like identifiers. + private static string ProcessingErrors(JsonArray errors) + { + var details = new List(); + foreach (var error in errors.OfType()) + { + try + { + foreach (var value in new[] { ReadString(error["code"]), ReadString(error["errorType"]) }) + { + if (value != null && details.Count < 8 && DiagnosticToken.IsMatch(value) && !details.Contains(value)) + { + details.Add(value); + } + } + } + catch (ArgumentException) + { + // An error whose properties repeat still counts; its codes are not read. + } + } + + return details.Count > 0 + ? $"processing errors: {errors.Count} ({string.Join(", ", details)})" + : $"processing errors: {errors.Count}"; + } + + /// + /// A verdict: a block stands whatever was cut; an allow of a truncated copy does not cover the rest, so it + /// follows the fail mode. + /// + private PurviewDlpEvaluationResult Verdict( + PurviewDlpActivity activity, + string correlationId, + string sessionId, + PurviewDlpDecision decision, + string? scopeState, + int status, + bool truncated, + long started) + { + var allowed = true; + string? error = null; + string? blockReason = null; + if (decision.BlockAction) + { + allowed = false; + blockReason = activity == PurviewDlpActivity.UploadText ? RequestBlocked : ResponseBlocked; + } + else if (truncated) + { + allowed = !Options.FailClosed; + error = TruncatedContentError; + blockReason = allowed ? null : TruncatedFailClosed; + } + + return new PurviewDlpEvaluationResult + { + Allowed = allowed, + Evaluated = true, + Truncated = truncated, + Activity = activity, + CorrelationId = correlationId, + SessionId = sessionId, + Decision = decision, + ProtectionScopeState = scopeState, + HttpStatus = status, + Latency = _timeProvider.GetElapsedTime(started), + Error = error, + BlockReason = blockReason, + }; + } + + private PurviewDlpEvaluationResult Failure( + PurviewDlpActivity activity, + string correlationId, + string sessionId, + string error, + int? status, + long started, + PurviewDlpDecision? decision = null, + string? scopeState = null) => new() + { + Allowed = !Options.FailClosed, + Evaluated = false, + Activity = activity, + CorrelationId = correlationId, + SessionId = sessionId, + Decision = decision ?? new PurviewDlpDecision(), + ProtectionScopeState = scopeState, + HttpStatus = status, + Latency = _timeProvider.GetElapsedTime(started), + Error = error, + BlockReason = Options.FailClosed ? UnavailableFailClosed : null, + }; + + private static string ActivityName(PurviewDlpActivity activity) => + activity == PurviewDlpActivity.UploadText ? "uploadText" : "downloadText"; + + private long NextSequence(string sessionId) + { + var next = _sequences.AddOrUpdate(sessionId, _ => Interlocked.Read(ref _sequenceFloor), (_, current) => current + 1); + if (_sequences.Count > MaxTrackedSessions) + { + foreach (var entry in _sequences.Where(entry => entry.Key != sessionId).Take(_sequences.Count - MaxTrackedSessions).ToList()) + { + // The floor rises before the session is dropped, and the session is dropped only if it was not + // numbered since, so a concurrent call for it cannot fall below the floor. + RaiseSequenceFloor(entry.Value + 1); + _sequences.TryRemove(entry); + } + } + + return next; + } + + private void RaiseSequenceFloor(long sequence) + { + var floor = Interlocked.Read(ref _sequenceFloor); + while (floor < sequence) + { + var observed = Interlocked.CompareExchange(ref _sequenceFloor, sequence, floor); + if (observed == floor) + { + return; + } + + floor = observed; + } + } + + /// + /// The text with lone surrogates replaced by U+FFFD: they are not valid UTF-16, so Graph cannot parse a request + /// that carries them. Valid surrogate pairs are kept. + /// + private static string ReplaceLoneSurrogates(string text) + { + var index = 0; + while (index < text.Length && !char.IsSurrogate(text[index])) + { + index++; + } + + if (index == text.Length) + { + return text; + } + + var builder = new StringBuilder(text.Length).Append(text, 0, index); + for (; index < text.Length; index++) + { + var character = text[index]; + if (char.IsHighSurrogate(character) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1])) + { + builder.Append(character).Append(text[++index]); + } + else + { + builder.Append(char.IsSurrogate(character) ? '\uFFFD' : character); + } + } + + return builder.ToString(); + } + + /// Moves a cut back one code unit when it would split a surrogate pair. + private static int KeepSurrogatePairs(string value, int cut) => + cut > 0 && cut < value.Length && char.IsHighSurrogate(value[cut - 1]) && char.IsLowSurrogate(value[cut]) ? cut - 1 : cut; + + private static string? ReadString(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out var text) ? text : null; + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + } +} diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs new file mode 100644 index 00000000..e4d9b24f --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling.Protection.Purview +{ + using System; + + /// The Purview activity an evaluation reports (activityMetadata.activity). + public enum PurviewDlpActivity + { + /// Content entering the agent, such as the user's prompt (uploadText). + UploadText, + + /// Content the agent returns, such as its reply (downloadText). + DownloadText, + } + + /// The policy actions Purview returned for an evaluation. + public sealed class PurviewDlpDecision + { + /// Whether a policy action's restrictionAction is block. + public bool BlockAction { get; init; } + + /// The restrictionAction of the blocking action, when one blocked. + public string? RestrictionAction { get; init; } + + /// How many policy actions Purview returned, blocking or not (for example audit or warn). + public int ActionCount { get; init; } + } + + /// + /// The outcome of one Purview evaluation. is false when no verdict was obtained; + /// then follows , as it does when Purview allowed a + /// copy. + /// + public sealed class PurviewDlpEvaluationResult + { + /// Whether the content may proceed. + public bool Allowed { get; init; } + + /// Whether Purview returned a verdict. + public bool Evaluated { get; init; } + + /// + /// Whether Purview evaluated a truncated copy: the text was longer than + /// . Its block stands; an allow does not cover what Purview + /// did not see, so follows and + /// is . + /// + public bool Truncated { get; init; } + + /// The activity that was evaluated. + public PurviewDlpActivity Activity { get; init; } + + /// + /// The client-request-id of the evaluation, a GUID unique to it and sent with its Graph call; Graph logs + /// the call under it. + /// + public string CorrelationId { get; init; } = string.Empty; + + /// The conversation or session the content belongs to (the content entry's correlationId). + public string? SessionId { get; init; } + + /// The policy actions Purview returned; empty when it returned none or no verdict was obtained. + public PurviewDlpDecision Decision { get; init; } = new PurviewDlpDecision(); + + /// Purview's protectionScopeState (modified or notModified), when returned. + public string? ProtectionScopeState { get; init; } + + /// The HTTP status, when a response was received. + public int? HttpStatus { get; init; } + + /// Time spent on the evaluation, including token acquisition. + public TimeSpan Latency { get; init; } + + /// + /// Why no verdict was obtained, or why Purview's allow does not stand, for example http 403. It never + /// carries a response body or a token; for an exception it names only the exception's type. + /// + public string? Error { get; init; } + + /// A user-facing reason when the content is blocked. + public string? BlockReason { get; init; } + } +} diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpOptions.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpOptions.cs new file mode 100644 index 00000000..837b7161 --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpOptions.cs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling.Protection.Purview +{ + using System; + using System.Globalization; + using System.Linq; + + /// How the agent's reply () is sent to Purview. + public enum PurviewDlpResponseMode + { + /// + /// The reply is sent to Purview for audit without waiting, and Purview never blocks it. Purview DLP policies for + /// custom AI apps restrict prompts (uploadText), not replies. + /// + Audit, + + /// The reply waits for Purview's verdict, which decides like the prompt's. + Enforce, + } + + /// + /// Configuration for Microsoft Purview data loss prevention (DLP) and audit through the Microsoft Graph + /// processContent API (POST .../dataSecurityAndGovernance/processContent). + /// + public sealed class PurviewDlpOptions + { + /// The default Microsoft Graph base URL. + public const string DefaultGraphBaseUrl = "https://graph.microsoft.com/v1.0"; + + /// The default token scope: Microsoft Graph. + public const string DefaultAuthenticationScope = "https://graph.microsoft.com/.default"; + + private static readonly string[] EnabledValues = { "true", "1", "yes", "on" }; + private static readonly string[] DisabledValues = { "false", "0", "no", "off" }; + + /// + /// Whether Purview DLP is enabled (ENABLE_A365_PURVIEW_DLP: true or false, or + /// 1/0, yes/no, on/off; unset means disabled). + /// + public bool Enabled { get; set; } + + /// + /// The Microsoft Graph base URL (A365_PURVIEW_DLP_GRAPH_BASE_URL), an absolute HTTPS URL without a query + /// or fragment; defaults to . + /// + public Uri GraphBaseUrl { get; set; } = new Uri(DefaultGraphBaseUrl); + + /// + /// The token scope (A365_PURVIEW_DLP_AUTHENTICATION_SCOPE); defaults to + /// . The token needs the Microsoft Graph permission + /// Content.Process.User (delegated, for the agentic user) or Content.Process.All. + /// + public string AuthenticationScope { get; set; } = DefaultAuthenticationScope; + + /// + /// The deadline for one evaluation, shared by token acquisition and the request + /// (A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS). When it passes, the result follows . + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// When true, an evaluation that returns no verdict blocks (A365_PURVIEW_DLP_FAIL_MODE=closed); + /// otherwise (open, the default) it is allowed and reported as not evaluated. + /// + public bool FailClosed { get; set; } + + /// + /// Maximum characters of text sent to Purview in one evaluation + /// (A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS, default 100000). Longer text is cut and sent with + /// isTruncated set: Purview's block of the cut text stands, but its allow does not cover what it did not + /// see, so the result follows . + /// + public int MaxContentCharacters { get; set; } = 100000; + + /// + /// How replies are sent to Purview (A365_PURVIEW_DLP_RESPONSE_MODE: audit, the default, or + /// enforce). Used by agent-hooks interceptors; the client evaluates whatever it is given. + /// + public PurviewDlpResponseMode ResponseMode { get; set; } = PurviewDlpResponseMode.Audit; + + /// Reads the options from the process environment. + /// The configured options. + /// A variable is set to a value it does not accept. + public static PurviewDlpOptions FromEnvironment() => FromEnvironment(Environment.GetEnvironmentVariable); + + /// + /// Reads the options from the given variable lookup. A value a variable does not accept fails rather than + /// falling back to a default, so a typo cannot quietly weaken protection: in particular, + /// ENABLE_A365_PURVIEW_DLP must say true or false, A365_PURVIEW_DLP_FAIL_MODE must be + /// open or closed, and A365_PURVIEW_DLP_RESPONSE_MODE must be audit or + /// enforce. + /// + /// Returns the value of an environment variable, or null. + /// The configured options. + /// A variable is set to a value it does not accept. + public static PurviewDlpOptions FromEnvironment(Func getVariable) + { + if (getVariable == null) + { + throw new ArgumentNullException(nameof(getVariable)); + } + + string? Read(string name) + { + var value = getVariable(name)?.Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } + + var options = new PurviewDlpOptions + { + Enabled = ParseEnabled(Read("ENABLE_A365_PURVIEW_DLP")), + FailClosed = ParseFailMode(Read("A365_PURVIEW_DLP_FAIL_MODE")), + ResponseMode = ParseResponseMode(Read("A365_PURVIEW_DLP_RESPONSE_MODE")), + }; + + if (Read("A365_PURVIEW_DLP_GRAPH_BASE_URL") is { } baseUrl) + { + options.GraphBaseUrl = Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) && IsHttpsBaseUrl(uri) + ? uri + : throw new InvalidOperationException( + "A365_PURVIEW_DLP_GRAPH_BASE_URL must be an absolute HTTPS URL without a query or fragment."); + } + + if (Read("A365_PURVIEW_DLP_AUTHENTICATION_SCOPE") is { } scope) + { + options.AuthenticationScope = scope; + } + + if (Read("A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS") is { } timeout) + { + options.Timeout = TimeSpan.FromMilliseconds(ParsePositive(timeout, "A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS")); + } + + if (Read("A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS") is { } maximum) + { + options.MaxContentCharacters = ParsePositive(maximum, "A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS"); + } + + return options; + } + + /// Throws when the options cannot be used by a client. + internal void Validate() + { + if (!IsHttpsBaseUrl(GraphBaseUrl)) + { + throw new InvalidOperationException( + "The Purview DLP Graph base URL must be an absolute HTTPS URL without a query or fragment " + + "(A365_PURVIEW_DLP_GRAPH_BASE_URL or PurviewDlpOptions.GraphBaseUrl)."); + } + + if (Timeout <= TimeSpan.Zero) + { + throw new InvalidOperationException("PurviewDlpOptions.Timeout must be positive."); + } + + if (MaxContentCharacters <= 0) + { + throw new InvalidOperationException("PurviewDlpOptions.MaxContentCharacters must be positive."); + } + + if (string.IsNullOrWhiteSpace(AuthenticationScope)) + { + throw new InvalidOperationException("PurviewDlpOptions.AuthenticationScope is required."); + } + + if (ResponseMode is not (PurviewDlpResponseMode.Audit or PurviewDlpResponseMode.Enforce)) + { + throw new InvalidOperationException("PurviewDlpOptions.ResponseMode must be Audit or Enforce."); + } + } + + /// + /// Whether the URL can be the Graph base: absolute and HTTPS, so tokens and content never travel in clear + /// text, with no query or fragment, so the API path appends to it. + /// + /// The URL to check. + /// True for an absolute https URL without a query or fragment. + internal static bool IsHttpsBaseUrl(Uri? url) => + url is { IsAbsoluteUri: true } + && string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && url.Query.Length == 0 + && url.Fragment.Length == 0; + + private static int ParsePositive(string value, string name) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0 + ? parsed + : throw new InvalidOperationException($"{name} must be a positive integer."); + + /// + /// Whether Purview DLP is enabled. Unset means disabled; a value that is not a recognized way of saying true + /// or false is rejected, so a typo cannot quietly turn protection off. + /// + private static bool ParseEnabled(string? value) + { + if (value == null || DisabledValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + if (EnabledValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + + throw new InvalidOperationException("ENABLE_A365_PURVIEW_DLP must be true or false."); + } + + /// Whether the fail mode is closed. Unset means open; any value but open or closed is rejected. + private static bool ParseFailMode(string? value) + { + if (value == null || value.Equals("open", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (value.Equals("closed", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + throw new InvalidOperationException("A365_PURVIEW_DLP_FAIL_MODE must be \"open\" or \"closed\"."); + } + + /// The response mode. Unset means audit; any value but audit or enforce is rejected. + private static PurviewDlpResponseMode ParseResponseMode(string? value) + { + if (value == null || value.Equals("audit", StringComparison.OrdinalIgnoreCase)) + { + return PurviewDlpResponseMode.Audit; + } + + if (value.Equals("enforce", StringComparison.OrdinalIgnoreCase)) + { + return PurviewDlpResponseMode.Enforce; + } + + throw new InvalidOperationException("A365_PURVIEW_DLP_RESPONSE_MODE must be \"audit\" or \"enforce\"."); + } + } +} diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs new file mode 100644 index 00000000..b61c0902 --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling.Protection.Purview +{ + using System; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Resolves the Microsoft Graph token for a Purview evaluation, and whose behalf the content is evaluated on. + /// + /// + /// The client does not cache what the resolver returns: the resolver owns caching. The resolvers in + /// use the agent's connection, whose MSAL implementation caches every token + /// of the agentic chain, or a host-supplied provider, which should cache its tokens. The resolver is called within + /// the evaluation's deadline (). + /// + /// The agent and turn being evaluated. + /// The scope to request, . + /// Cancels the acquisition when the evaluation's deadline passes. + /// The token, or null when none is available. + public delegate Task PurviewDlpTokenResolver( + PurviewDlpAgentContext agent, + string scope, + CancellationToken cancellationToken); + + /// A Microsoft Graph access token, and the user the content is evaluated for. + public sealed class PurviewDlpToken + { + /// Initializes a new instance of the class. + /// The Microsoft Graph access token. + /// + /// The user to evaluate for, which selects /users/{userId}; null selects /me, the token's own + /// user, as for a delegated token. + /// + /// The access token is empty, or the user id is empty but not null. + public PurviewDlpToken(string accessToken, string? userId = null) + { + if (string.IsNullOrWhiteSpace(accessToken)) + { + throw new ArgumentException("The access token is required.", nameof(accessToken)); + } + + if (userId != null && string.IsNullOrWhiteSpace(userId)) + { + throw new ArgumentException("The user id must not be empty; pass null to evaluate as /me.", nameof(userId)); + } + + AccessToken = accessToken; + UserId = userId; + } + + /// The Microsoft Graph access token. + public string AccessToken { get; } + + /// The user to evaluate for (/users/{UserId}), or null for the token's own user (/me). + public string? UserId { get; } + } + + /// The agent and turn a Purview evaluation is for. + public sealed class PurviewDlpAgentContext + { + /// + /// The agent identity (agent instance application) id, for example turnContext.Activity.GetAgenticInstanceId(). + /// Sent as the agent's identifier, and used by . + /// + public string? AgentId { get; set; } + + /// + /// The agent's tenant id, for example turnContext.Activity.GetAgenticTenantId(). Used by + /// . + /// + public string? TenantId { get; set; } + + /// + /// The agentic user's object id, for example turnContext.Activity.GetAgenticUser(). Used by + /// . + /// + public string? AgenticUserId { get; set; } + + /// The agent blueprint's application id, sent as the agent's blueprintId. + public string? BlueprintId { get; set; } + + /// + /// The Entra application id Purview DLP policies are scoped to (protectedAppMetadata.applicationLocation). + /// Defaults to , then . + /// + public string? ApplicationId { get; set; } + + /// The agent's display name, which names the app and the content in Purview; defaults to agent365-agent. + public string? AgentName { get; set; } + + /// + /// The conversation or session that groups the content (the content entry's correlationId), for example + /// the conversation id. Required. Agent-hooks interceptors use the context's session.id. + /// + public string? SessionId { get; set; } + + /// + /// The content's position in the session (sequenceNumber). When unset, the client numbers each + /// session's evaluations in the order they are made. Agent-hooks interceptors use the context's + /// sequence. + /// + public long? SequenceNumber { get; set; } + + /// + /// A copy with the given session and sequence, for example those of an agent-hooks context; this instance is + /// not modified, so one context can serve every evaluation of a turn. + /// + /// The session id, or null to keep . + /// The sequence number, or null to keep . + /// The copy. + public PurviewDlpAgentContext WithSession(string? sessionId, long? sequenceNumber) + { + var copy = (PurviewDlpAgentContext)MemberwiseClone(); + copy.SessionId = string.IsNullOrWhiteSpace(sessionId) ? SessionId : sessionId; + copy.SequenceNumber = sequenceNumber ?? SequenceNumber; + return copy; + } + } +} diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs new file mode 100644 index 00000000..2351ece3 --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling.Protection.Purview +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.Authentication; + + /// Token resolvers for . + public static class PurviewDlpTokenResolvers + { + /// + /// The agentic user's delegated Microsoft Graph token, issued through the agent's Agents SDK connection, so + /// content is evaluated as the agent's agentic user (/me). The connection's blueprint credential issues the + /// agent identity's assertion and exchanges it for the agentic user's token + /// (); MSAL connections cache each token of that + /// chain. The token needs the delegated permission Content.Process.User, which agentic users inherit + /// from their blueprint's Microsoft Graph grant. + /// + /// + /// The evaluation's must carry , + /// and , for + /// example from the incoming activity: GetAgenticTenantId(), GetAgenticInstanceId() and + /// GetAgenticUser(). Without them the resolver throws, and the evaluation follows the fail mode. + /// + /// + /// The agent's connection, for example connections.GetDefaultConnection() cast to + /// (MSAL connections implement it). + /// + /// A resolver for . + public static PurviewDlpTokenResolver FromAgenticUser(IAgenticTokenProvider connection) + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + return async (agent, scope, cancellationToken) => + { + if (agent == null) + { + throw new ArgumentNullException(nameof(agent)); + } + + var tenantId = Require(agent.TenantId, nameof(PurviewDlpAgentContext.TenantId)); + var agentId = Require(agent.AgentId, nameof(PurviewDlpAgentContext.AgentId)); + var agenticUserId = Require(agent.AgenticUserId, nameof(PurviewDlpAgentContext.AgenticUserId)); + var token = await connection + .GetAgenticUserTokenAsync(tenantId, agentId, agenticUserId, new List { scope }, cancellationToken) + .ConfigureAwait(false); + return string.IsNullOrWhiteSpace(token) ? null : new PurviewDlpToken(token); + }; + } + + /// + /// A Microsoft Graph token from the host's own token provider, for example an on-behalf-of token for the + /// signed-in user, which evaluates as /me. With , content is evaluated for that + /// user (/users/{userId}), as an app-only token with the application permission + /// Content.Process.All requires; that path has not been validated end to end yet. The provider should + /// cache its tokens: the client asks for one on every evaluation. + /// + /// Returns an access token for the given scope; it observes the cancellation token. + /// The user to evaluate for, or null for the token's own user. + /// A resolver for . + /// The user id is empty but not null. + public static PurviewDlpTokenResolver FromAccessTokenProvider( + Func> getToken, + string? userId = null) + { + if (getToken == null) + { + throw new ArgumentNullException(nameof(getToken)); + } + + if (userId != null && string.IsNullOrWhiteSpace(userId)) + { + throw new ArgumentException("The user id must not be empty; pass null to evaluate as /me.", nameof(userId)); + } + + return async (_, scope, cancellationToken) => + { + var token = await getToken(scope, cancellationToken).ConfigureAwait(false); + return string.IsNullOrWhiteSpace(token) ? null : new PurviewDlpToken(token, userId); + }; + } + + private static string Require(string? value, string name) => + string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException($"The agentic user token requires {name}.", name) + : value!; + } +} diff --git a/src/Tooling/Core/docs/design.md b/src/Tooling/Core/docs/design.md index 65894867..6c489f0a 100644 --- a/src/Tooling/Core/docs/design.md +++ b/src/Tooling/Core/docs/design.md @@ -379,12 +379,18 @@ src/Tooling/Core/ │ ├── HttpContextHeadersHandler.cs # Header propagation │ └── HttpLoggingHandler.cs # Logging handler ├── Protection/ -│ └── Defender/ -│ ├── DefenderRtpClient.cs # Defender prevention endpoint client -│ ├── DefenderRtpOptions.cs # Configuration (environment variables) -│ ├── DefenderRtpEvaluationResult.cs # Verdict and evaluation result -│ ├── DefenderRtpTokenResolver.cs # Token resolver delegate, agent context -│ └── DefenderRtpTokenResolvers.cs # Agent identity token exchange +│ ├── Defender/ +│ │ ├── DefenderRtpClient.cs # Defender prevention endpoint client +│ │ ├── DefenderRtpOptions.cs # Configuration (environment variables) +│ │ ├── DefenderRtpEvaluationResult.cs # Verdict and evaluation result +│ │ ├── DefenderRtpTokenResolver.cs # Token resolver delegate, agent context +│ │ └── DefenderRtpTokenResolvers.cs # Agent identity token exchange +│ └── Purview/ +│ ├── PurviewDlpClient.cs # Microsoft Graph processContent client +│ ├── PurviewDlpOptions.cs # Configuration (environment variables), response mode +│ ├── PurviewDlpEvaluationResult.cs # Activity, policy actions and evaluation result +│ ├── PurviewDlpTokenResolver.cs # Token resolver delegate, token, agent context +│ └── PurviewDlpTokenResolvers.cs # Agentic user and host-supplied tokens ├── Constants.cs # Constants ├── Utility.cs # Helper methods ├── Microsoft.Agents.A365.Tooling.csproj @@ -511,6 +517,40 @@ if (result is { Allowed: false }) } ``` +### Data Loss Prevention (Microsoft Purview) + +`PurviewDlpClient` sends a text to the Microsoft Graph `processContent` API, which applies the Purview DLP policies +scoped to the agent's application and writes the Purview audit record: `UploadText` for content entering the agent, +such as the user's prompt, and `DownloadText` for its reply. A policy action restricted with `block` blocks. The +agentic user's delegated token (`Content.Process.User`) evaluates as `/me`. Agent-hooks hosts register it through +`A365PurviewInterceptor` in `Microsoft.Agents.A365.Tooling.Extensions.AgentHooks` (see its +[README](../../Extensions/AgentHooks/README.md)). + +```csharp +var purview = new PurviewDlpClient(PurviewDlpOptions.FromEnvironment(), httpClient); +var tokens = PurviewDlpTokenResolvers.FromAgenticUser( + (IAgenticTokenProvider)connections.GetDefaultConnection()); + +var result = await purview.EvaluateAsync( + PurviewDlpActivity.UploadText, + userMessage, + new PurviewDlpAgentContext + { + AgentId = turnContext.Activity.GetAgenticInstanceId(), + TenantId = turnContext.Activity.GetAgenticTenantId(), + AgenticUserId = turnContext.Activity.GetAgenticUser(), + BlueprintId = blueprintId, // the agent blueprint's application id, which DLP policies are scoped to + SessionId = turnContext.Activity.Conversation.Id, + }, + tokens, + cancellationToken); + +if (result is { Allowed: false }) +{ + // Blocked: result.BlockReason; Graph logs the call under result.CorrelationId (client-request-id). +} +``` + ### Dependency Injection Setup ```csharp diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index dfb2b649..5c5e333c 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -11,6 +11,7 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AgentHooks using System.Threading.Tasks; using global::AgentHooks; using Microsoft.Agents.A365.Tooling.Protection.Defender; + using Microsoft.Agents.A365.Tooling.Protection.Purview; using Microsoft.Extensions.Logging; /// The agent identity and credentials for the Defender call of one emitted context. @@ -219,21 +220,35 @@ public static class A365AgentHooks { /// /// Creates an emitter for Agent 365 protection: enforce mode and the parallel/strictest - /// profile (an action proceeds only when every interceptor allows it). + /// profile (an action proceeds only when every interceptor allows it), so Defender and Purview compose on one + /// emitter and a deny from either wins. /// /// - /// Per-interceptor timeout; defaults to the Defender timeout plus two seconds. The client evaluates - /// within one deadline, , that covers token acquisition and the - /// request, so its fail mode applies before this timeout turns into an agent-hooks host error. + /// Per-interceptor timeout; defaults to the longest timeout of the given Defender and Purview options plus two + /// seconds (ten seconds plus two when neither is given). Each client evaluates within one deadline, its + /// options' Timeout, that covers token acquisition and the request, so its fail mode applies before this + /// timeout turns into an agent-hooks host error. /// - /// The Defender options whose timeout sets the default. + /// The Defender options, whose timeout sets the default. + /// The Purview options, whose timeout sets the default. /// The configured emitter. - public static InterceptionEmitter CreateProtectionEmitter(TimeSpan? interceptorTimeout = null, DefenderRtpOptions? defender = null) => - new InterceptionEmitter( + public static InterceptionEmitter CreateProtectionEmitter( + TimeSpan? interceptorTimeout = null, + DefenderRtpOptions? defender = null, + PurviewDlpOptions? purview = null) + { + var slowest = defender?.Timeout; + if (purview != null && (slowest == null || purview.Timeout > slowest)) + { + slowest = purview.Timeout; + } + + return new InterceptionEmitter( EnforcementMode.Enforce, null, - interceptorTimeout ?? (defender?.Timeout ?? TimeSpan.FromSeconds(10)) + TimeSpan.FromSeconds(2)) + interceptorTimeout ?? (slowest ?? TimeSpan.FromSeconds(10)) + TimeSpan.FromSeconds(2)) .SetComposition(CompositionConfig.Strictest(SynthesisPolicy.Deny)); + } /// Registers the Defender interceptor under the name defender. /// The emitter. @@ -248,5 +263,19 @@ public static InterceptionEmitter AddA365Defender(this InterceptionEmitter emitt return emitter.Register(interceptor ?? throw new ArgumentNullException(nameof(interceptor)), A365DefenderInterceptor.Name); } + + /// Registers the Purview interceptor under the name purview. + /// The emitter. + /// The Purview interceptor. + /// The emitter. + public static InterceptionEmitter AddA365Purview(this InterceptionEmitter emitter, A365PurviewInterceptor interceptor) + { + if (emitter == null) + { + throw new ArgumentNullException(nameof(emitter)); + } + + return emitter.Register(interceptor ?? throw new ArgumentNullException(nameof(interceptor)), A365PurviewInterceptor.Name); + } } } diff --git a/src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs new file mode 100644 index 00000000..309776c2 --- /dev/null +++ b/src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling.Extensions.AgentHooks +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Text; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using global::AgentHooks; + using Microsoft.Agents.A365.Tooling.Protection.Purview; + using Microsoft.Extensions.Logging; + + /// The agent and credentials for the Purview call of one emitted context. + /// + /// The agent and turn. It is not modified: the evaluation uses a copy with the context's session.id and + /// sequence. + /// + /// Resolves the Microsoft Graph token, for example + /// . + public sealed record A365PurviewCall(PurviewDlpAgentContext Agent, PurviewDlpTokenResolver TokenResolver); + + /// + /// An agent-hooks interceptor for Microsoft Purview data loss prevention (DLP) and audit. The user's message at + /// input is evaluated as uploadText, and a policy action that blocks it denies the action. The reply + /// at output is evaluated as downloadText: by default for audit only, without waiting + /// (), or, with , waiting for + /// a verdict that decides like the input's. Other points are allowed without a call. + /// + /// + /// + /// The text is the content's string, or every string and number in structured content, one per line. Empty content + /// is allowed without a call. Each evaluation uses the context's session id as the content's + /// correlationId and its sequence as sequenceNumber. + /// + /// + /// When no verdict is obtained (no agent identity, identity resolution, token, transport or HTTP failure, or + /// processing errors Graph reports inline), or Purview allowed a truncated copy + /// (), the verdict follows + /// : allow with a purview:unverified warning, or deny with reason + /// runtime_error:purview_unverified, which is never reported as a detection. A reply audited in the + /// background is never blocked, whatever the fail mode. + /// + /// + /// Register it on an emitter whose per-interceptor timeout is longer than , + /// such as one from ; a shorter timeout turns a slow evaluation + /// into a fail-closed agent-hooks host error. The evaluation callback runs on the thread pool once the verdict is + /// decided, outside that timeout, and never changes the verdict: an exception it throws is logged and ignored. + /// + /// + public sealed class A365PurviewInterceptor : IInterceptor + { + /// The name the interceptor is registered under. + public const string Name = "purview"; + + private const string NoIdentity = "no agent identity was resolved"; + private const string Blocked = "The content was blocked by a Microsoft Purview data loss prevention policy."; + private const string UnavailableFailClosed = + "Data loss prevention validation is unavailable and this agent is configured to fail closed."; + + private readonly PurviewDlpClient _client; + private readonly Func _resolveCall; + private readonly Action? _onEvaluated; + private readonly ILogger? _logger; + + /// Initializes a new instance of the class. + /// The Purview client. + /// + /// Returns the agent and token resolver for a context, for example from the current turn. It is invoked only + /// when Purview DLP is enabled, at input and output, for content with text. When it returns null + /// (no agent identity is available) or throws, the content is not sent and the verdict follows the fail mode; + /// the result records only the type of an exception, which is logged to . + /// + /// + /// Receives each evaluation for logging and telemetry (for example the correlation id), including the + /// background audit of replies. It runs on the thread pool once the verdict is decided, so it may run after the + /// interceptor returns and alongside later evaluations. It never changes the verdict: an exception it throws is + /// logged to , when given, and ignored. + /// + /// Receives failures of the evaluation and of . + public A365PurviewInterceptor( + PurviewDlpClient client, + Func resolveCall, + Action? onEvaluated = null, + ILogger? logger = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _resolveCall = resolveCall ?? throw new ArgumentNullException(nameof(resolveCall)); + _onEvaluated = onEvaluated; + _logger = logger; + } + + /// + public async ValueTask InterceptAsync(AgentContext context, CancellationToken cancellationToken) + { + if (!_client.Options.Enabled) + { + return new Verdict(Decision.Allow); + } + + var point = InterceptionPointExtensions.ToWireName(context.InterceptionPoint); + PurviewDlpActivity activity; + JsonNode? content; + switch (point) + { + case "input": + activity = PurviewDlpActivity.UploadText; + content = (context.Json["input"] as JsonObject)?["content"]; + break; + case "output": + activity = PurviewDlpActivity.DownloadText; + content = (context.Json["output"] as JsonObject)?["content"]; + break; + default: + return new Verdict(Decision.Allow); + } + + // Content without text has nothing for Purview to inspect. + var text = ContentText(content); + if (string.IsNullOrWhiteSpace(text)) + { + return new Verdict(Decision.Allow); + } + + var sessionId = ReadString((context.Json["session"] as JsonObject)?["id"]); + var sequence = ReadSequence(context.Json["sequence"]); + if (activity == PurviewDlpActivity.DownloadText && _client.Options.ResponseMode == PurviewDlpResponseMode.Audit) + { + AuditInBackground(context, point, text, sessionId, sequence); + return new Verdict(Decision.Allow); + } + + PurviewDlpEvaluationResult? result; + try + { + // Without an agent identity Purview cannot be called; like any missing verdict, that follows the fail + // mode rather than allowing silently. + result = ResolveCall(context, sessionId, sequence) is { } call + ? await _client.EvaluateAsync(activity, text, call.Agent, call.TokenResolver, cancellationToken).ConfigureAwait(false) + : _client.Unavailable(activity, NoIdentity, sessionId); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + // A failure to resolve the identity or to evaluate is never a verdict: it follows the fail mode. Only the + // exception's type reaches the result, since its message can carry credentials or content; the exception + // itself goes to the logger. + _logger?.LogWarning(ex, "The Purview evaluation failed at {InterceptionPoint}; the fail mode applies.", point); + result = _client.Unavailable(activity, $"evaluation failed ({ex.GetType().Name})", sessionId); + } + + if (result == null) + { + return new Verdict(Decision.Allow); + } + + // The verdict is decided before the callback sees the result, and the callback runs on the thread pool, off + // the emitter's timed interception. + var verdict = ToVerdict(result); + Report(result); + return verdict; + } + + /// Maps a Purview evaluation to the agent-hooks verdict the host composes. + /// The Purview evaluation. + /// The agent-hooks verdict. + public static Verdict ToVerdict(PurviewDlpEvaluationResult result) + { + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + // Purview's allow of a truncated copy does not cover the text that was cut, so it is reported like a missing + // verdict; a block of a truncated copy stands. + var authoritative = result.Evaluated && !(result.Truncated && !result.Decision.BlockAction); + if (authoritative) + { + return result.Allowed + ? new Verdict(Decision.Allow) + : new Verdict( + Decision.Deny, + Reason: $"{Name}:block", + Message: result.BlockReason ?? Blocked, + Evidence: new Evidence( + $"{Name}-verdict", + new Dictionary { ["correlation"] = $"urn:a365:{Name}:{Uri.EscapeDataString(result.CorrelationId)}" })); + } + + var unverified = new[] { new Warning($"{Name}:unverified", result.Error ?? "no verdict was returned") }; + return result.Allowed + ? new Verdict(Decision.Allow, Warnings: unverified) + : new Verdict( + Decision.Deny, + Reason: $"runtime_error:{Name}_unverified", + Message: result.BlockReason ?? UnavailableFailClosed, + Warnings: unverified); + } + + /// + /// The text Purview inspects in input or output content: a string as it is; otherwise every string and number in + /// the content, in document order, one per line. Property names are structure, not content, and are left out. + /// The walk keeps its own stack, so content of any depth is read in full. + /// + internal static string ContentText(JsonNode? content) + { + if (content is JsonValue single && single.TryGetValue(out var only)) + { + return only; + } + + var builder = new StringBuilder(); + var pending = new Stack(); + pending.Push(content); + while (pending.Count > 0) + { + switch (pending.Pop()) + { + case JsonArray array: + for (var index = array.Count - 1; index >= 0; index--) + { + pending.Push(array[index]); + } + + break; + case JsonObject obj: + foreach (var property in obj.Reverse()) + { + pending.Push(property.Value); + } + + break; + case JsonValue value when value.TryGetValue(out var text): + AppendLine(builder, text); + break; + case JsonValue value when value.GetValueKind() == JsonValueKind.Number: + AppendLine(builder, value.ToJsonString()); + break; + } + } + + return builder.ToString(); + } + + private static void AppendLine(StringBuilder builder, string text) + { + if (text.Length == 0) + { + return; + } + + if (builder.Length > 0) + { + builder.Append('\n'); + } + + builder.Append(text); + } + + /// + /// Evaluates the reply for audit without waiting: the call is resolved here, on the interception, where the turn + /// is at hand, and the evaluation runs on the thread pool, bounded by the client's own deadline rather than the + /// emitter's token. Its outcome, or a failure, goes only to the callback. + /// + private void AuditInBackground(AgentContext context, string point, string text, string? sessionId, long? sequence) + { + (PurviewDlpAgentContext Agent, PurviewDlpTokenResolver TokenResolver)? call; + try + { + call = ResolveCall(context, sessionId, sequence); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Resolving the Purview call failed at {InterceptionPoint}; the reply is not audited.", point); + Report(_client.Unavailable(PurviewDlpActivity.DownloadText, $"evaluation failed ({ex.GetType().Name})", sessionId)); + return; + } + + if (call is not { } resolved) + { + Report(_client.Unavailable(PurviewDlpActivity.DownloadText, NoIdentity, sessionId)); + return; + } + + _ = Task.Run(async () => + { + PurviewDlpEvaluationResult? result; + try + { + result = await _client + .EvaluateAsync(PurviewDlpActivity.DownloadText, text, resolved.Agent, resolved.TokenResolver, CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "The Purview audit of the reply failed at {InterceptionPoint}.", point); + result = _client.Unavailable(PurviewDlpActivity.DownloadText, $"evaluation failed ({ex.GetType().Name})", sessionId); + } + + if (result != null && _onEvaluated is { } onEvaluated) + { + NotifyEvaluated(onEvaluated, result); + } + }); + } + + /// + /// The agent and resolver for the context, with the context's session and sequence, or null when no agent + /// identity was resolved. + /// + private (PurviewDlpAgentContext Agent, PurviewDlpTokenResolver TokenResolver)? ResolveCall( + AgentContext context, + string? sessionId, + long? sequence) => + _resolveCall(context) is { Agent: { } agent, TokenResolver: { } tokenResolver } + ? (agent.WithSession(sessionId, sequence), tokenResolver) + : null; + + /// Hands a result to the callback on the thread pool, off the emitter's timed interception. + private void Report(PurviewDlpEvaluationResult result) + { + if (_onEvaluated is { } onEvaluated) + { + _ = Task.Run(() => NotifyEvaluated(onEvaluated, result)); + } + } + + /// + /// Hands the evaluation to the host's callback. The callback is for logging and telemetry, so its failure is + /// logged at most and never changes the verdict, in either fail mode. + /// + private void NotifyEvaluated(Action onEvaluated, PurviewDlpEvaluationResult result) + { + try + { + onEvaluated(result); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _logger?.LogWarning( + ex, + "The Purview evaluation callback failed for {Activity}; the verdict is unchanged. CorrelationId: {CorrelationId}", + result.Activity, + result.CorrelationId); + } + } + + private static string? ReadString(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out var text) ? text : null; + + // Reads the number's JSON text, so a sequence a host built from any integer type counts. + private static long? ReadSequence(JsonNode? node) => + node is JsonValue value + && value.GetValueKind() == JsonValueKind.Number + && long.TryParse(value.ToJsonString(), NumberStyles.None, CultureInfo.InvariantCulture, out var sequence) + ? sequence + : null; + } +} diff --git a/src/Tooling/Extensions/AgentHooks/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.csproj b/src/Tooling/Extensions/AgentHooks/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.csproj index 8800d76b..420b3f2b 100644 --- a/src/Tooling/Extensions/AgentHooks/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.csproj +++ b/src/Tooling/Extensions/AgentHooks/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.csproj @@ -8,10 +8,10 @@ Microsoft.Agents.A365.Tooling.Extensions.AgentHooks Microsoft Agent 365 agent-hooks protection - Microsoft Agent 365 real-time protection on the agent-hooks control contract (AGENT-HOOKS-0.1): a Microsoft Defender for AI interceptor for any agent-hooks host. - Microsoft Agent 365 agent-hooks protection - Microsoft Defender for AI interceptor + Microsoft Agent 365 real-time protection on the agent-hooks control contract (AGENT-HOOKS-0.1): Microsoft Defender for AI and Microsoft Purview data loss prevention interceptors for any agent-hooks host. + Microsoft Agent 365 agent-hooks protection - Microsoft Defender for AI and Microsoft Purview DLP interceptors Microsoft Agent 365 Tooling SDK - Microsoft;Agent365;A365;AgentHooks;Defender;Security;AI;Agents + Microsoft;Agent365;A365;AgentHooks;Defender;Purview;DLP;Security;AI;Agents $(NoWarn);NU5104 diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index b9c593c0..1244514a 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -4,8 +4,11 @@ Microsoft Agent 365 real-time protection on the [agent-hooks](https://github.com control contract (AGENT-HOOKS-0.1), using the .NET package [`ResponsibleAI.AgentHooks`](https://www.nuget.org/packages/ResponsibleAI.AgentHooks). -`A365DefenderInterceptor` is an agent-hooks interceptor for Microsoft Defender for AI. Each context the host -emits at the four points Defender evaluates is sent to the prevention endpoint +`A365DefenderInterceptor` is an agent-hooks interceptor for Microsoft Defender for AI, and `A365PurviewInterceptor` +one for Microsoft Purview data loss prevention (DLP) and audit (see [Microsoft Purview DLP](#microsoft-purview-dlp)). +Both register on one emitter, where a deny from either wins. + +Each context the host emits at the four points Defender evaluates is sent to the prevention endpoint (`POST .../v1/protection/evaluate`) as a fitted copy: the client builds a copy of the context that meets Defender's request validation (spec version, UTC timestamp, `target`, envelope and tool objects with only their spec members, repaired optional fields and clamped content), keeping its session, sequence and tool call ids. The @@ -101,8 +104,9 @@ Agents built on Microsoft Agent Framework can register the same interceptor with **Interceptor timeout.** The client evaluates within one deadline, `DefenderRtpOptions.Timeout` (default 10 s), that covers token acquisition and the request, and applies the fail mode when it passes. The emitter's per-interceptor timeout must be longer: when it fires first, agent-hooks records a fail-closed host error even -if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` sets it to -`DefenderRtpOptions.Timeout` plus two seconds. If you register the interceptor on another emitter (for example +if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` sets it to the longest timeout of the +options it is given (`DefenderRtpOptions.Timeout`, `PurviewDlpOptions.Timeout`) plus two seconds. If you register the +interceptor on another emitter (for example `new InterceptionEmitter()`, whose default is 5 s, or through Agent Framework), set its interceptor timeout above `DefenderRtpOptions.Timeout`, or lower `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS`. @@ -140,5 +144,154 @@ surrogates in any string are replaced with U+FFFD, since Defender cannot parse t Every call sends a unique `x-ms-correlation-id`, returned as `DefenderRtpEvaluationResult.CorrelationId`; Defender logs each evaluation under it. A `400` reports the failed validation rule in `Error`. +## Microsoft Purview DLP + +`A365PurviewInterceptor` sends the agent's prompts and replies to Microsoft Purview through the Microsoft Graph +`processContent` API (`POST https://graph.microsoft.com/v1.0/me/dataSecurityAndGovernance/processContent`). Purview +applies the tenant's DLP policies scoped to the agent's application and writes the audit record that Purview Audit +and Data Security Posture Management (DSPM) for AI show. + +| agent-hooks point | Purview activity | By default | On a block | +|---|---|---|---| +| `input` | `uploadText`: the user's message | waits for the verdict | the agent does not run | +| `output` | `downloadText`: the reply | audit: sent without waiting, never blocked | with `enforce`, the reply is replaced | + +Other points are allowed without a call; tool calls and tool results are not sent to Purview. The text sent is the +content's string, or every string and number in structured content, one per line; content without text is allowed +without a call. Each call is one conversation entry: the context's `session.id` is its `correlationId`, its +`sequence` the entry's `sequenceNumber`, and the entry names the agent (`agents`: the blueprint id, the agent +identity id and the agent's name). Policies match on the entry's application +(`protectedAppMetadata.applicationLocation`), `PurviewDlpAgentContext.ApplicationId`, which defaults to the blueprint +id, then the agent identity id. + +A policy action whose `restrictionAction` is `block` blocks: the verdict denies with reason `purview:block`, the +message "The request was blocked by a Microsoft Purview data loss prevention policy." and the evidence +`urn:a365:purview:`. Other actions (for example audit) allow and are counted in +`PurviewDlpEvaluationResult.Decision`. Every call sends a new `client-request-id`, returned as +`PurviewDlpEvaluationResult.CorrelationId`; Graph logs the call under it. + +**Replies.** Purview DLP policies for custom AI apps restrict prompts (`uploadText`) and cannot block replies. By +default (`A365_PURVIEW_DLP_RESPONSE_MODE=audit`) the interceptor allows the reply at once, whatever the fail mode, and +sends it to Purview in the background, so it is audited without delaying the turn. The background evaluation is +bounded by the client's own timeout rather than the emitter's cancellation, contains its failures, and reports to the +evaluation callback. `enforce` waits for the verdict on replies and maps it as at `input`. + +**Fail mode.** When no verdict is obtained (no agent identity, a token, transport, HTTP or timeout failure, an +unexpected response, or `processingErrors` that Graph reports inline: an invalid request comes back as HTTP 200 with a +processing error and no policy actions), the result follows `A365_PURVIEW_DLP_FAIL_MODE`. `open` (the default) allows +with a `purview:unverified` warning; `closed` denies with `runtime_error:purview_unverified`, which is never reported as +a detection. A block stands even when Graph reports processing errors, or actions of another shape, with it. `Error` +records only the status, the identifier-like codes of processing errors, or an exception's type; never a response body +or a token. + +**Content size.** Text longer than `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` (default 100000) is cut and sent with +`isTruncated` set. Purview's block of the cut text stands; its allow does not cover what Purview did not see, so the +result follows the fail mode (`PurviewDlpEvaluationResult.Truncated`, with `Error` set to `TruncatedContentError`). +Lone UTF-16 surrogates are replaced with U+FFFD. + +### Purview authentication + +`PurviewDlpTokenResolvers.FromAgenticUser` asks the agent's connection +(`IAgenticTokenProvider.GetAgenticUserTokenAsync`) for the agentic user's delegated Microsoft Graph token, which +carries `Content.Process.User`, and evaluates as `/me`: the agent's agentic user. It needs the turn's tenant, agent +identity and agentic user, from the incoming activity: `GetAgenticTenantId()`, `GetAgenticInstanceId()` and +`GetAgenticUser()`. MSAL connections cache each token of the agentic chain, so the client does not cache tokens itself. + +`PurviewDlpTokenResolvers.FromAccessTokenProvider` uses a Microsoft Graph token from the host instead, for example an +on-behalf-of token for the signed-in user (`/me`), or an app-only token with the application permission +`Content.Process.All` for a given user (`/users/{userId}`). The app-only path has not been validated end to end yet. +The provider should cache its tokens, since the client asks for one on every evaluation. + +`protectionScopes/compute` is not called: `processContent` applies the policies itself, and computing scopes needs +another permission (`ProtectionScopes.Compute.User`). + +### Purview tenant setup + +1. **Licensing and billing.** Microsoft 365 E5 or E5 Compliance (or equivalent) for the users and agents, and + Microsoft Purview pay-as-you-go billing linked to an Azure subscription: DLP for AI apps is metered. Without them, + `processContent` returns no policy actions and nothing is blocked. +2. **DSPM for AI** is onboarded in the Microsoft Purview portal. +3. **A DLP policy on the AI app location.** In the Microsoft Purview portal, create a custom DLP policy whose only + location is **Managed cloud apps** (the Applications workload), scoped to the agent blueprint's application id, + with a rule whose condition matches the sensitive information to protect (for example credit card numbers) and + whose action restricts access with **Block** for prompts (`uploadText`). Keep it a dedicated policy: the AI app + location cannot be combined with Exchange, SharePoint, OneDrive or Teams locations. A new policy can take up to an + hour to apply. +4. **`Content.Process.User` for agentic users.** Add the delegated Microsoft Graph permission `Content.Process.User` + to the blueprint's tenant-wide (`AllPrincipals`) delegated grant for Microsoft Graph, appending it to the grant's + existing scopes rather than replacing them. Re-consenting the blueprint's API permissions instead can drop the + scopes its agents rely on. The blueprint's inheritable permissions for Microsoft Graph, which `a365 setup` + configures as `allAllowed`, pass it on to every agentic user. With Microsoft Graph (as a Cloud Application + Administrator, Privileged Role Administrator or Global Administrator): + - find the grant: `GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{blueprint-sp-object-id}' and consentType eq 'AllPrincipals' and resourceId eq '{microsoft-graph-sp-object-id}'`; + - append the scope: `PATCH https://graph.microsoft.com/v1.0/oauth2PermissionGrants/{grant-id}` with + `{"scope":"{existing scopes} Content.Process.User"}`. + +### Purview usage + +Defender and Purview on one emitter: + +```csharp +using AgentHooks; +using Microsoft.Agents.A365.Tooling.Extensions.AgentHooks; +using Microsoft.Agents.A365.Tooling.Protection.Defender; +using Microsoft.Agents.A365.Tooling.Protection.Purview; +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Builder; + +var connection = (IAgenticTokenProvider)connections.GetDefaultConnection(); +var defender = new DefenderRtpClient(DefenderRtpOptions.FromEnvironment(), httpClient); +var purview = new PurviewDlpClient(PurviewDlpOptions.FromEnvironment(), httpClient); +var defenderTokens = DefenderRtpTokenResolvers.FromAgenticConnection(connection, httpClient); +var purviewTokens = PurviewDlpTokenResolvers.FromAgenticUser(connection); +var activity = turnContext.Activity; + +var emitter = A365AgentHooks.CreateProtectionEmitter(defender: defender.Options, purview: purview.Options) + .AddA365Defender(new A365DefenderInterceptor( + defender, + context => new A365DefenderCall( + new DefenderRtpAgentContext { AgentId = activity.GetAgenticInstanceId(), TenantId = activity.GetAgenticTenantId() }, + defenderTokens))) + .AddA365Purview(new A365PurviewInterceptor( + purview, + context => new A365PurviewCall( + new PurviewDlpAgentContext + { + AgentId = activity.GetAgenticInstanceId(), // the agent identity + TenantId = activity.GetAgenticTenantId(), // the agent's tenant + AgenticUserId = activity.GetAgenticUser(), // the agentic user the token is for + BlueprintId = blueprintId, // the application DLP policies are scoped to + AgentName = "SampleAgent", + }, + purviewTokens), + result => logger.LogInformation( + "Purview {Activity} allowed={Allowed} evaluated={Evaluated} actions={Actions} cid={CorrelationId}", + result.Activity, result.Allowed, result.Evaluated, result.Decision.ActionCount, result.CorrelationId))); +``` + +The factory, like Defender's, is invoked only when Purview DLP is enabled, at `input` and `output`, for content with +text. When it returns null or throws, or resolving the token fails, the verdict follows the fail mode (except for a +reply audited in the background). `A365AgentHooks.CreateProtectionEmitter` sizes the emitter's per-interceptor timeout +to the longer of the two clients' timeouts plus two seconds. + +### Purview configuration + +| Variable | Meaning | +|---|---| +| `ENABLE_A365_PURVIEW_DLP` | `true` to call Purview; `false` or unset leaves it off (`1`/`0`, `yes`/`no` and `on`/`off` also work); any other value is rejected | +| `A365_PURVIEW_DLP_GRAPH_BASE_URL` | the Microsoft Graph base URL, an absolute HTTPS URL without a query or fragment (default `https://graph.microsoft.com/v1.0`) | +| `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | the token scope (default `https://graph.microsoft.com/.default`) | +| `A365_PURVIEW_DLP_FAIL_MODE` | `open` (default) or `closed`, which blocks when no verdict is obtained; any other value is rejected | +| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | the deadline for one evaluation, including token acquisition (default 10000) | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | the most text sent in one evaluation (default 100000). See **Content size** | +| `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` (default) or `enforce`, which waits for Purview's verdict on replies; any other value is rejected | + +### Purview limitations + +- Tool calls and tool results are not sent to Purview. +- `protectionScopes/compute`, and caching what it returns, are not used: every evaluation calls `processContent`. +- Purview DLP cannot block the replies of custom AI apps; replies are audited by default. +- The app-only path (`/users/{userId}` with `Content.Process.All`) has not been validated end to end. + `ResponsibleAI.AgentHooks` is a prerelease package with a native core (`agent_hooks_ffi`) for linux-x64, win-x64, osx-x64 and osx-arm64. From 3a0421f1af66fb417a38e6cb53a21a3048930ca5 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala <12496535+DheerajPannala@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:34:29 +0100 Subject: [PATCH 2/2] Align Purview with the Node SDK and bound structured content reading - Block on restrictionAction "block" or action "blockAccess" (any case); a block stands next to processing errors or malformed actions. - One GUID per call: the client-request-id and the content entry identifier. - The agent name defaults to the agent identity id (then the application). - Read structured content only up to MaxContentCharacters, in values as well as characters; text cut there is sent as a truncated copy, and content cut before any text was read follows the fail mode instead of being skipped. - CreateProtectionEmitter counts Purview's timeout only when Purview is enabled, so Defender-only emitters are unchanged. - FromAgenticUser caches the agentic user's token per tenant, agent, user and scope until it expires (background refresh, shared acquisition, failures never cached); host-supplied tokens are never cached. Co-authored-by: Dominik Bezic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94b4d34e-4cd4-4156-afd9-b486121d2b41 --- CHANGELOG.md | 19 +- .../Protection/PurviewDlpClientTests.cs | 70 +++++- .../PurviewDlpTokenResolversTests.cs | 179 ++++++++++++++- .../A365PurviewInterceptorTests.cs | 115 ++++++++++ .../Core/Microsoft.Agents.A365.Tooling.csproj | 1 + .../Purview/AgenticUserTokenCache.cs | 215 ++++++++++++++++++ .../Protection/Purview/PurviewDlpClient.cs | 102 ++++++--- .../Purview/PurviewDlpEvaluationResult.cs | 7 +- .../Purview/PurviewDlpTokenResolver.cs | 14 +- .../Purview/PurviewDlpTokenResolvers.cs | 51 +++-- src/Tooling/Core/docs/design.md | 8 +- .../AgentHooks/A365DefenderInterceptor.cs | 13 +- .../AgentHooks/A365PurviewInterceptor.cs | 151 ++++++++---- src/Tooling/Extensions/AgentHooks/README.md | 55 +++-- 14 files changed, 857 insertions(+), 143 deletions(-) create mode 100644 src/Tooling/Core/Protection/Purview/AgenticUserTokenCache.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 30fc47ee..2afa85a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,12 +97,14 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. (`POST {base}/me/dataSecurityAndGovernance/processContent`) as `uploadText` (content entering the agent) or `downloadText` (its reply): one conversation entry with the agent, the session (`correlationId`) and its sequence, scoped to the agent's application (the blueprint id by default). A policy action whose - `restrictionAction` is `block` blocks; other actions allow and are counted. Each call sends a new - `client-request-id`, returned as `PurviewDlpEvaluationResult.CorrelationId`. + `restrictionAction` is `block`, or whose `action` is `blockAccess`, blocks; other actions allow and are counted. + Each call sends a new `client-request-id`, which also identifies its content entry and is returned as + `PurviewDlpEvaluationResult.CorrelationId`. - Tokens come from a `PurviewDlpTokenResolver`: `PurviewDlpTokenResolvers.FromAgenticUser` uses the agent's connection (`IAgenticTokenProvider.GetAgenticUserTokenAsync`) for the agentic user's delegated Microsoft Graph - token (`Content.Process.User`) and evaluates as `/me`; `FromAccessTokenProvider` takes a host-supplied token, for - `/me` or for a given user (`/users/{id}`). + token (`Content.Process.User`), evaluates as `/me`, and caches the token per tenant, agent, agentic user and scope + until it expires (refreshed in the background before it does; failures never cached); `FromAccessTokenProvider` + takes a host-supplied token, never cached, for `/me` or for a given user (`/users/{id}`). - Configured with `ENABLE_A365_PURVIEW_DLP`, `A365_PURVIEW_DLP_GRAPH_BASE_URL` (default `https://graph.microsoft.com/v1.0`, absolute HTTPS only), `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE`, `A365_PURVIEW_DLP_FAIL_MODE` (`open` or `closed`), `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` (one deadline for token @@ -114,10 +116,11 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. - **Microsoft.Agents.A365.Tooling.Extensions.AgentHooks** - `A365PurviewInterceptor` (`AddA365Purview`), an agent-hooks interceptor for Purview DLP: the user's message at `input` is evaluated as `uploadText` and a block denies it (`purview:block`); the reply at `output` is evaluated as `downloadText`, for audit in the background by - default or waiting for the verdict with `A365_PURVIEW_DLP_RESPONSE_MODE=enforce`. Missing verdicts follow the fail - mode (`purview:unverified` or `runtime_error:purview_unverified`). `A365AgentHooks.CreateProtectionEmitter` takes - optional `PurviewDlpOptions`, so Defender and Purview compose on one emitter whose interceptor timeout exceeds - the longer of their timeouts. + default or waiting for the verdict with `A365_PURVIEW_DLP_RESPONSE_MODE=enforce`. Structured content is read only up + to `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS`. Missing verdicts follow the fail mode (`purview:unverified` or + `runtime_error:purview_unverified`). `A365AgentHooks.CreateProtectionEmitter` takes optional `PurviewDlpOptions`, + so Defender and Purview compose on one emitter whose interceptor timeout exceeds the longer of their timeouts + (Purview's counts only when Purview DLP is enabled). - **Microsoft.Agents.A365.Tooling** - V1/V2 per-audience token support for MCP servers - `MCPServerConfig` extended with `audience`, `scope`, `publisher`, and `Headers` fields - `IMcpTokenProvider` interface for pluggable OAuth token acquisition diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs index 2d7ec1d1..2ffde280 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs @@ -90,8 +90,7 @@ public async Task SendsTheTextAsAConversationEntryWithAUniqueClientRequestId() second!.CorrelationId.Should().Be(handler.Calls[1].ClientRequestId); var entry = call.Entry; - Guid.TryParse(entry["identifier"]!.GetValue(), out _).Should().BeTrue(); - entry["identifier"]!.GetValue().Should().NotBe(call.ClientRequestId); + entry["identifier"]!.GetValue().Should().Be(call.ClientRequestId, "one id per call identifies the request and its content entry"); var expected = JsonNode.Parse($$""" { "contentToProcess": { @@ -183,19 +182,21 @@ public async Task DeclaresTheAgentWithoutABlueprintItDoesNotKnow() } [Theory] - [InlineData(null)] - [InlineData(" ")] - public async Task NamesTheEntryEvenWithoutAnAgentName(string? agentName) + [InlineData(null, AgentId, AgentId)] + [InlineData(" ", AgentId, AgentId)] + [InlineData(null, null, BlueprintId)] + public async Task NamesTheEntryEvenWithoutAnAgentName(string? agentName, string? agentId, string expected) { var (client, handler) = Create(_ => Graph(Clean)); var agent = Agent(); agent.AgentName = agentName; + agent.AgentId = agentId; await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", agent, Tokens); var body = handler.Calls.Single().Body; - body["contentToProcess"]!["contentEntries"]![0]!["name"]!.GetValue().Should().Be("agent365-agent uploadText", "Graph rejects an entry without a name"); - body["contentToProcess"]!["protectedAppMetadata"]!["name"]!.GetValue().Should().Be(PurviewDlpClient.DefaultAgentName); + body["contentToProcess"]!["contentEntries"]![0]!["name"]!.GetValue().Should().Be($"{expected} uploadText", "Graph rejects an entry without a name"); + body["contentToProcess"]!["protectedAppMetadata"]!["name"]!.GetValue().Should().Be(expected); } [Fact] @@ -334,6 +335,22 @@ public async Task MatchesTheBlockActionInAnyCase() result.Decision.Should().BeEquivalentTo(new { BlockAction = true, RestrictionAction = "BLOCK", ActionCount = 2 }); } + [Theory] + [InlineData("""{"policyActions":[{"@odata.type":"#microsoft.graph.blockAccessAction","action":"blockAccess"}]}""", null)] + [InlineData("""{"policyActions":[{"action":"BLOCKACCESS","restrictionAction":"warn"}]}""", "warn")] + [InlineData("""{"policyActions":[{"action":7,"restrictionAction":"block"}]}""", "block")] + [InlineData("""{"policyActions":[{"action":"blockAccess","restrictionAction":{"mode":"audit"}}]}""", null)] + public async Task BlocksOnABlockAccessActionToo(string body, string? restrictionAction) + { + var (client, _) = Create(_ => Graph(body)); + + var result = await client.EvaluateAsync(PurviewDlpActivity.UploadText, "hello", Agent(), Tokens); + + result!.Evaluated.Should().BeTrue(); + result.Allowed.Should().BeFalse("an action blocks when its action is blockAccess or its restrictionAction is block"); + result.Decision.Should().BeEquivalentTo(new { BlockAction = true, RestrictionAction = restrictionAction, ActionCount = 1 }); + } + [Fact] public async Task AllowsActionsThatDoNotBlockAndCountsThem() { @@ -451,6 +468,7 @@ public async Task FollowsTheFailModeOnAnHttpErrorWithoutReadingItsBody(bool fail [InlineData("""{"policyActions":["block"]}""", "response contained unexpected policyActions")] [InlineData("""{"policyActions":[{"restrictionAction":{"value":"block"}}]}""", "response contained unexpected policyActions")] [InlineData("""{"policyActions":[{"restrictionAction":"audit","restrictionAction":"block"}]}""", "response contained unexpected policyActions")] + [InlineData("""{"policyActions":[{"action":7,"restrictionAction":"audit"}]}""", "response contained unexpected policyActions")] [InlineData("""{"policyActions":[],"processingErrors":{"code":"BadRequest"}}""", "response contained unexpected processingErrors")] [InlineData("""{"policyActions":[{"restrictionAction":"block"}],"policyActions":[]}""", "response contained duplicate properties")] [InlineData("not json", "non-JSON response")] @@ -533,6 +551,44 @@ public async Task DoesNotSplitASurrogatePairWhenTruncating() handler.Calls.Single().Text.Should().Be("abc"); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TreatsAnAllowOfATextAlreadyCutAsNotCoveringTheContent(bool failClosed) + { + var (client, handler) = Create(DeniesThePayload, new PurviewDlpOptions { FailClosed = failClosed }); + + var allowed = await client.EvaluateTextAsync(PurviewDlpActivity.UploadText, "the first part", true, Agent(), Tokens); + var blocked = await client.EvaluateTextAsync(PurviewDlpActivity.UploadText, "the first part " + Payload, true, Agent(), Tokens); + + handler.Calls.Should().OnlyContain(call => call.Entry["isTruncated"]!.GetValue()); + allowed!.Truncated.Should().BeTrue(); + allowed.Allowed.Should().Be(!failClosed); + allowed.Error.Should().Be(PurviewDlpClient.TruncatedContentError); + blocked!.Allowed.Should().BeFalse("a block of a cut text stands"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeForABlankTextAlreadyCut(bool failClosed) + { + var (client, handler) = Create(_ => Graph(Clean), new PurviewDlpOptions { FailClosed = failClosed }); + var resolved = 0; + + var result = await client.EvaluateTextAsync(PurviewDlpActivity.UploadText, " \n ", true, Agent(), (_, _, _) => + { + resolved++; + return Task.FromResult(new PurviewDlpToken(AccessToken)); + }); + + result!.Evaluated.Should().BeFalse("what was not read could hold text"); + result.Allowed.Should().Be(!failClosed); + result.Error.Should().Be(PurviewDlpClient.UnreadContentError); + handler.Calls.Should().BeEmpty(); + resolved.Should().Be(0); + } + // ─── transport and deadline ────────────────────────────────────────────── [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs index 29ab48e5..9c8bf91e 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs @@ -35,7 +35,7 @@ public async Task AsksTheConnectionForTheAgenticUsersTokenAndEvaluatesAsMe() AgentId, AgenticUserId, It.Is>(scopes => scopes.SequenceEqual(new[] { "https://graph.microsoft.com/.default" })), - cancellation.Token), + It.IsAny()), Times.Once); } @@ -124,6 +124,183 @@ public async Task EvaluatesAsMeWithoutAUser() token!.UserId.Should().BeNull(); } + [Fact] + public async Task NeverCachesTheHostsTokens() + { + var issued = 0; + var resolver = PurviewDlpTokenResolvers.FromAccessTokenProvider((_, _) => Task.FromResult(Jwt(DateTimeOffset.UtcNow.AddHours(1), ++issued))); + + var first = await resolver(Agent(), "scope", CancellationToken.None); + var second = await resolver(Agent(), "scope", CancellationToken.None); + + issued.Should().Be(2, "a host token may be for a user the agent context does not identify"); + second!.AccessToken.Should().NotBe(first!.AccessToken); + } + + [Fact] + public async Task CachesTheAgenticUsersTokenPerUserUntilItExpires() + { + var clock = new ManualClock(); + var issued = new List(); + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticUserTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((string _, string _, string user, IList _, CancellationToken _) => + { + issued.Add(user); + return Jwt(clock.Now.AddHours(1), issued.Count); + }); + var resolver = PurviewDlpTokenResolvers.CreateAgenticUserResolver(connection.Object, clock, TimeSpan.FromSeconds(30)); + var other = Agent(); + other.AgenticUserId = "eeeeeeee-0000-4000-8000-000000000005"; + + var first = await resolver(Agent(), "scope", CancellationToken.None); + var again = await resolver(Agent(), "scope", CancellationToken.None); + var otherUser = await resolver(other, "scope", CancellationToken.None); + var otherScope = await resolver(Agent(), "other-scope", CancellationToken.None); + clock.Now += TimeSpan.FromHours(2); + var afterExpiry = await resolver(Agent(), "scope", CancellationToken.None); + + again!.AccessToken.Should().Be(first!.AccessToken); + otherUser!.AccessToken.Should().NotBe(first.AccessToken, "another agentic user's token is never reused"); + otherScope!.AccessToken.Should().NotBe(first.AccessToken); + afterExpiry!.AccessToken.Should().NotBe(first.AccessToken); + issued.Should().Equal(AgenticUserId, "eeeeeeee-0000-4000-8000-000000000005", AgenticUserId, AgenticUserId); + } + + [Fact] + public async Task RefreshesEarlyAndKeepsTheCachedTokenWhenTheRefreshFails() + { + var clock = new ManualClock(); + var attempts = 0; + Exception? failure = null; + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticUserTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(() => + { + attempts++; + return failure is null ? Task.FromResult(Jwt(clock.Now.AddHours(1), attempts)) : Task.FromException(failure); + }); + var resolver = PurviewDlpTokenResolvers.CreateAgenticUserResolver(connection.Object, clock, TimeSpan.FromSeconds(30)); + var cached = (await resolver(Agent(), "scope", CancellationToken.None))!.AccessToken; + failure = new InvalidOperationException("Entra is unavailable"); + + clock.Now += TimeSpan.FromMinutes(57); + var withinRefreshWindow = await resolver(Agent(), "scope", CancellationToken.None); + await WaitUntil(() => attempts == 2); + + withinRefreshWindow!.AccessToken.Should().Be(cached, "the cached token is used while it is valid"); + clock.Now += TimeSpan.FromMinutes(4); + var afterExpiry = () => resolver(Agent(), "scope", CancellationToken.None); + await afterExpiry.Should().ThrowAsync("a failed refresh is never cached, and an expired token is never used"); + } + + [Fact] + public async Task SharesOneAcquisitionAndLetsItFinishForCallersThatStopWaiting() + { + var clock = new ManualClock(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var attempts = 0; + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticUserTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(async () => + { + Interlocked.Increment(ref attempts); + await release.Task; + return Jwt(clock.Now.AddHours(1), 1); + }); + var resolver = PurviewDlpTokenResolvers.CreateAgenticUserResolver(connection.Object, clock, TimeSpan.FromSeconds(30)); + using var impatient = new CancellationTokenSource(); + + var abandoned = resolver(Agent(), "scope", impatient.Token); + var waiting = resolver(Agent(), "scope", CancellationToken.None); + impatient.Cancel(); + var stopWaiting = () => abandoned; + await stopWaiting.Should().ThrowAsync(); + release.SetResult(); + var token = await waiting; + var later = await resolver(Agent(), "scope", CancellationToken.None); + + attempts.Should().Be(1, "concurrent callers share one acquisition, which a caller that stops waiting does not cancel"); + later!.AccessToken.Should().Be(token!.AccessToken); + } + + [Fact] + public async Task ReleasesAnAcquisitionWhoseConnectionIgnoresCancellation() + { + var never = new TaskCompletionSource(); + var attempts = 0; + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticUserTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(() => ++attempts == 1 ? never.Task : Task.FromResult(Jwt(DateTimeOffset.UtcNow.AddHours(1), attempts))); + var resolver = PurviewDlpTokenResolvers.CreateAgenticUserResolver(connection.Object, TimeProvider.System, TimeSpan.FromMilliseconds(200)); + + var first = () => resolver(Agent(), "scope", CancellationToken.None); + await first.Should().ThrowAsync(); + var second = await resolver(Agent(), "scope", CancellationToken.None); + + second.Should().NotBeNull("an acquisition that never ends does not hold the key"); + attempts.Should().Be(2); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DoesNotCacheATokenWithoutAReadableExpiryOrUseAnExpiredOne(bool expired) + { + var attempts = 0; + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticUserTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(() => ++attempts > 0 && expired ? Jwt(DateTimeOffset.UtcNow.AddMinutes(-1), attempts) : "opaque-token"); + var resolver = PurviewDlpTokenResolvers.FromAgenticUser(connection.Object); + + var act = () => resolver(Agent(), "scope", CancellationToken.None); + + if (expired) + { + await act.Should().ThrowAsync(); + } + else + { + (await act())!.AccessToken.Should().Be("opaque-token"); + await act(); + attempts.Should().Be(2, "a token without a readable expiry is used but not cached"); + } + } + + [Fact] + public async Task KeepsTheCacheBounded() + { + var clock = new ManualClock(); + var cache = new AgenticUserTokenCache((key, _) => Task.FromResult(Jwt(clock.Now.AddHours(1), key.AgenticUserId.GetHashCode())), clock, TimeSpan.FromSeconds(30)); + + await Task.WhenAll(Enumerable.Range(0, 150).Select(index => cache.GetAsync(new AgenticUserTokenKey(TenantId, AgentId, $"user-{index}", "scope"), CancellationToken.None))); + + cache.Count.Should().BeLessThanOrEqualTo(AgenticUserTokenCache.MaxEntries); + } + + /// A JWT that expires at the given time; the seed makes each one distinct. + private static string Jwt(DateTimeOffset expiresAt, int seed) + { + static string Encode(string json) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return $"{Encode("""{"alg":"none"}""")}.{Encode($$"""{"exp":{{expiresAt.ToUnixTimeSeconds()}},"n":{{seed}}}""")}.signature"; + } + + private static async Task WaitUntil(Func condition) + { + var waited = System.Diagnostics.Stopwatch.StartNew(); + while (!condition() && waited.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(10); + } + + condition().Should().BeTrue(); + } + [Theory] [InlineData("")] [InlineData(" ")] diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs index 3c6e751c..06c5c817 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs @@ -317,6 +317,88 @@ public async Task SendsEveryStringAndNumberOfStructuredContent() .Should().Be("text\nBook the hotel\nimage_url\nhttps://images.example.test/a.png\n4111111111111111"); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReadsStructuredContentOnlyUpToTheLimit(bool failClosed) + { + var harness = new Harness(BlocksThePayload, failClosed: failClosed, maxContentCharacters: 100); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-21"); + var parts = new JsonArray(Enumerable.Range(0, 5000).Select(_ => (JsonNode?)JsonValue.Create("abcdefghi")).ToArray()); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(parts), CancellationToken.None); + + var entry = Entry(harness.Bodies.Single()); + entry["content"]!["data"]!.GetValue().Should().HaveLength(100); + entry["isTruncated"]!.GetValue().Should().BeTrue(); + AssertFollowsTheFailMode(record, failClosed); + record.Verdict.Warnings.Should().Contain(warning => warning.Message == PurviewDlpClient.TruncatedContentError); + } + + [Fact] + public async Task BlocksWhatItReadOfStructuredContentCutAtTheLimit() + { + var harness = new Harness(BlocksThePayload, maxContentCharacters: 100); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-22"); + var parts = new JsonArray(new JsonNode?[] { JsonValue.Create($"card {Payload}") }.Concat(Enumerable.Range(0, 500).Select(_ => (JsonNode?)null)).ToArray()); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(parts), CancellationToken.None); + + Entry(harness.Bodies.Single())["isTruncated"]!.GetValue().Should().BeTrue("reading stopped at the limit of values"); + record.Proceeds.Should().BeFalse("a block of what was read stands"); + record.Verdict.Reason.Should().Be("purview:block"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenReadingStopsBeforeAnyText(bool failClosed) + { + var resolved = 0; + var harness = new Harness(_ => Graph(Clean), failClosed: failClosed, maxContentCharacters: 100, resolveCall: _ => + { + resolved++; + return new A365PurviewCall(Agent(), Tokens); + }); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-23"); + var padding = new JsonArray(Enumerable.Range(0, 500).Select(index => (JsonNode?)(index % 2 == 0 ? JsonValue.Create(" ") : null)).ToArray()); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(padding), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed); + harness.Bodies.Should().BeEmpty(); + resolved.Should().Be(0); + (await harness.EvaluationsAsync()).Should().ContainSingle().Which.Error.Should().Be(PurviewDlpClient.UnreadContentError); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeForContentThatCannotBeRead(bool failClosed) + { + var evaluations = new List(); + var client = new PurviewDlpClient( + new PurviewDlpOptions { Enabled = true, GraphBaseUrl = new Uri(GraphBase), FailClosed = failClosed }, + new HttpClient(new FakeGraph((_, _) => Task.FromResult(Graph(Clean)), new List(), new List<(Uri Uri, string RequestId)>()))); + var interceptor = new A365PurviewInterceptor(client, _ => new A365PurviewCall(Agent(), Tokens), evaluations.Add); + var context = new AgentContextBuilder(AgentId, "agent-framework", "s-24").Input(JsonValue.Create("placeholder")!); + + // A parsed object whose property appears twice throws only when it is read. + context.Json["input"]!["content"] = JsonNode.Parse("""{"text":"one","text":"two"}"""); + var verdict = await interceptor.InterceptAsync(context, CancellationToken.None); + + if (failClosed) + { + verdict.Decision.Should().Be(Decision.Deny); + verdict.Reason.Should().Be("runtime_error:purview_unverified"); + } + else + { + verdict.Decision.Should().Be(Decision.Allow); + verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "purview:unverified" && warning.Message == "content could not be read (ArgumentException)"); + } + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -447,6 +529,22 @@ public async Task SizesTheInterceptorTimeoutForTheSlowerProvider() record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "purview:unverified" && warning.Message == "request timeout"); } + [Fact] + public async Task SizesTheInterceptorTimeoutWithoutDisabledPurviewOptions() + { + var defender = new DefenderRtpOptions { Enabled = true, Endpoint = new Uri(DefenderEndpoint), Timeout = TimeSpan.FromMilliseconds(100) }; + var purview = new PurviewDlpOptions { Enabled = false, GraphBaseUrl = new Uri(GraphBase), Timeout = TimeSpan.FromSeconds(10) }; + + // Counted while disabled, Purview's timeout would stretch the emitter's to 12 s and let this 3 s interceptor finish. + var emitter = A365AgentHooks.CreateProtectionEmitter(defender: defender, purview: purview) + .Register(new SlowInterceptor(TimeSpan.FromSeconds(3)), "slow"); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-25"); + + var record = await emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + record.Proceeds.Should().BeFalse("a Defender-only emitter keeps Defender's timeout plus two seconds"); + } + [Fact] public async Task ComposesDefenderAndPurviewOnOneEmitter() { @@ -654,6 +752,23 @@ protected override async Task SendAsync(HttpRequestMessage } } + /// An interceptor that takes a while and then allows. + private sealed class SlowInterceptor : IInterceptor + { + private readonly TimeSpan _delay; + + public SlowInterceptor(TimeSpan delay) + { + _delay = delay; + } + + public async ValueTask InterceptAsync(AgentContext context, CancellationToken ct) + { + await Task.Delay(_delay, CancellationToken.None); + return new Verdict(Decision.Allow); + } + } + private sealed class FakeGraph : HttpMessageHandler { private readonly Func> _respond; diff --git a/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj b/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj index bdcbe152..f0427edd 100644 --- a/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj +++ b/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Tooling/Core/Protection/Purview/AgenticUserTokenCache.cs b/src/Tooling/Core/Protection/Purview/AgenticUserTokenCache.cs new file mode 100644 index 00000000..320e23af --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/AgenticUserTokenCache.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling.Protection.Purview +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + + /// Identifies an agentic user token by its parts, so no two users, agents or scopes can share one. + internal readonly record struct AgenticUserTokenKey(string TenantId, string AgentId, string AgenticUserId, string Scope); + + /// + /// Caches agentic user tokens per tenant, agent identity, agentic user and scope until they expire, and refreshes a + /// token in the background within five minutes of its expiry while the cached one stays in use until it actually + /// expires. Concurrent requests for one key share an acquisition, which is bounded by its own timeout rather than + /// any caller's cancellation and leaves the in-flight map as it completes, so a failure is never handed to a later + /// caller and never cached. A token whose expiry cannot be read is used but not cached. + /// + internal sealed class AgenticUserTokenCache + { + /// The most tokens the cache holds. + internal const int MaxEntries = 100; + + private static readonly TimeSpan RefreshSkew = TimeSpan.FromMinutes(5); + + private readonly Func> _acquire; + private readonly TimeProvider _timeProvider; + private readonly TimeSpan _acquisitionTimeout; + private readonly ConcurrentDictionary _tokens = new(); + private readonly ConcurrentDictionary>> _inFlight = new(); + + // Serializes writes to _tokens, so concurrent acquisitions cannot push it past MaxEntries. + private readonly object _writeLock = new(); + + /// Initializes a new instance of the class. + /// Acquires a token for a key; it observes the cancellation token. + /// The clock. + /// How long one acquisition may take. + public AgenticUserTokenCache( + Func> acquire, + TimeProvider timeProvider, + TimeSpan acquisitionTimeout) + { + _acquire = acquire; + _timeProvider = timeProvider; + _acquisitionTimeout = acquisitionTimeout; + } + + /// The number of cached tokens; for tests. + internal int Count => _tokens.Count; + + /// + /// The token for the key: the cached one until it expires, or the result of an acquisition, which the caller + /// waits for until it is cancelled. + /// + /// The token's tenant, agent identity, agentic user and scope. + /// Stops waiting; the acquisition itself goes on, so its token is cached. + /// The token, or null when the connection returned none. + public async Task GetAsync(AgenticUserTokenKey key, CancellationToken cancellationToken) + { + var now = _timeProvider.GetUtcNow(); + if (_tokens.TryGetValue(key, out var cached) && now < cached.ExpiresAt) + { + if (now >= cached.ExpiresAt - RefreshSkew) + { + // A failed refresh is retried by a later call; the cached token stays in use until it expires. + ObserveFailure(StartOrJoin(key)); + } + + return cached.Token; + } + + return await StartOrJoin(key).WaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// The acquisition in flight for the key, if any; for tests. Its task completes only after the acquisition has + /// left the in-flight map, so awaiting it orders a later call after it. + /// + internal Task? Pending(AgenticUserTokenKey key) => + _inFlight.TryGetValue(key, out var acquisition) ? acquisition.Value : null; + + /// Observes the failure of a task no caller awaits, so it is not reported as unobserved. + private static void ObserveFailure(Task task) => + _ = task.ContinueWith( + static completed => { _ = completed.Exception; }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + private static DateTimeOffset? ReadExpiry(string token) + { + var parts = token.Split('.'); + if (parts.Length != 3) + { + return null; + } + + try + { + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight(payload.Length + ((4 - (payload.Length % 4)) % 4), '='); + if ((JsonNode.Parse(Convert.FromBase64String(payload)) as JsonObject)?["exp"] is not JsonValue exp) + { + return null; + } + + if (exp.TryGetValue(out var seconds)) + { + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + + return exp.TryGetValue(out var fractional) + ? DateTimeOffset.FromUnixTimeSeconds((long)fractional) + : null; + } + catch (Exception ex) when (ex is FormatException or JsonException or ArgumentException) + { + return null; + } + } + + private Task StartOrJoin(AgenticUserTokenKey key) + { + while (true) + { + if (_inFlight.TryGetValue(key, out var inFlight)) + { + return inFlight.Value; + } + + Lazy>? acquisition = null; + acquisition = new Lazy>(() => AcquireAsync(key, acquisition!)); + if (_inFlight.TryAdd(key, acquisition)) + { + return acquisition.Value; + } + } + } + + private async Task AcquireAsync(AgenticUserTokenKey key, Lazy> acquisition) + { + try + { + using var timeout = new CancellationTokenSource(_acquisitionTimeout, _timeProvider); + + // Started on the thread pool, so a connection that blocks before it returns its task holds no caller, + // and bounded by the timeout as well as asked to stop, so one that ignores cancellation cannot hold the + // key's acquisition forever. + var acquiring = Task.Run(() => _acquire(key, timeout.Token), CancellationToken.None); + string? token; + try + { + token = await acquiring.WaitAsync(timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + ObserveFailure(acquiring); + throw; + } + + if (!string.IsNullOrWhiteSpace(token) && ReadExpiry(token!) is { } expiresAt) + { + if (expiresAt <= _timeProvider.GetUtcNow()) + { + throw new InvalidOperationException("The connection returned an expired agentic user token."); + } + + Store(key, new CachedToken(token!, expiresAt)); + } + + return token; + } + finally + { + // Runs before the acquisition's task completes, so no caller joins a finished or failed one. + _inFlight.TryRemove(new KeyValuePair>>(key, acquisition)); + } + } + + /// + /// Caches a token within : at capacity, expired tokens go first, then the one closest to + /// expiry. + /// + private void Store(AgenticUserTokenKey key, CachedToken token) + { + lock (_writeLock) + { + if (!_tokens.ContainsKey(key) && _tokens.Count >= MaxEntries) + { + var now = _timeProvider.GetUtcNow(); + foreach (var stale in _tokens.Where(entry => entry.Value.ExpiresAt <= now).Select(entry => entry.Key).ToList()) + { + _tokens.TryRemove(stale, out _); + } + + if (_tokens.Count >= MaxEntries && _tokens.OrderBy(entry => entry.Value.ExpiresAt).FirstOrDefault() is { Value: not null } oldest) + { + _tokens.TryRemove(oldest.Key, out _); + } + } + + _tokens[key] = token; + } + } + + private sealed record CachedToken(string Token, DateTimeOffset ExpiresAt); + } +} diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs index 1305590e..a35679ea 100644 --- a/src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs @@ -25,7 +25,8 @@ namespace Microsoft.Agents.A365.Tooling.Protection.Purview /// Each evaluation sends one text to POST {GraphBaseUrl}/me/dataSecurityAndGovernance/processContent (or /// /users/{id}/... when the token resolver names a user). Purview applies the DLP policies scoped to the /// agent's application and writes the audit record. A policy action whose restrictionAction is - /// block blocks; other actions allow. Each call carries a unique client-request-id, returned as + /// block, or whose action is blockAccess, blocks; other actions allow. Each call carries a + /// unique client-request-id, also the identifier of its content entry, returned as /// . /// public sealed class PurviewDlpClient @@ -40,8 +41,12 @@ public sealed class PurviewDlpClient /// public const string TruncatedContentError = "content exceeded MaxContentCharacters; Purview evaluated a truncated copy"; - /// The agent name used when is not set. - public const string DefaultAgentName = "agent365-agent"; + /// + /// The of a result for structured content whose reading stopped at + /// before any text was found: what was not read could hold + /// text, so Purview is not skipped silently and the result follows . + /// + public const string UnreadContentError = "content exceeded MaxContentCharacters before any text was read"; private const string ProcessContentPath = "/dataSecurityAndGovernance/processContent"; private const string AppVersion = "1.0"; @@ -126,9 +131,24 @@ public PurviewDlpClient( /// Cancels the evaluation. /// The result, or null (and no call) when Purview DLP is disabled or the text is empty. /// The agent context has no session id or application id. - public async Task EvaluateAsync( + public Task EvaluateAsync( + PurviewDlpActivity activity, + string? text, + PurviewDlpAgentContext agent, + PurviewDlpTokenResolver tokenResolver, + CancellationToken cancellationToken = default) => + EvaluateTextAsync(activity, text, false, agent, tokenResolver, cancellationToken); + + /// + /// Evaluates a text that may already be a truncated copy of the content, for example the text read from + /// structured content within . A truncated copy is sent with + /// isTruncated and Purview's allow of it follows the fail mode, as for text cut here; a blank one cannot + /// be evaluated and follows the fail mode too (). + /// + internal async Task EvaluateTextAsync( PurviewDlpActivity activity, string? text, + bool truncatedCopy, PurviewDlpAgentContext agent, PurviewDlpTokenResolver tokenResolver, CancellationToken cancellationToken = default) @@ -153,7 +173,8 @@ public PurviewDlpClient( throw new ArgumentOutOfRangeException(nameof(activity)); } - if (string.IsNullOrWhiteSpace(text)) + var blank = string.IsNullOrWhiteSpace(text); + if (blank && !truncatedCopy) { return null; } @@ -163,13 +184,21 @@ public PurviewDlpClient( var applicationId = FirstNonEmpty(agent.ApplicationId, agent.BlueprintId, agent.AgentId) ?? throw new ArgumentException("The agent context's ApplicationId, BlueprintId or AgentId is required.", nameof(agent)); var started = _timeProvider.GetTimestamp(); + + // One id per evaluation: the client-request-id of its call, and the identifier of its content entry. var correlationId = _idFactory().ToString(); + if (blank) + { + return Failure(activity, correlationId, sessionId, UnreadContentError, null, started); + } + var sequence = agent.SequenceNumber is long given && given >= 0 ? given : NextSequence(sessionId); // Text over the limit is cut, never split inside a surrogate pair, and sent with isTruncated set. var maxCharacters = Options.MaxContentCharacters; - var truncated = text.Length > maxCharacters; - var data = ReplaceLoneSurrogates(truncated ? text.Substring(0, KeepSurrogatePairs(text, maxCharacters)) : text); + var cut = text!.Length > maxCharacters; + var truncated = truncatedCopy || cut; + var data = ReplaceLoneSurrogates(cut ? text.Substring(0, KeepSurrogatePairs(text, maxCharacters)) : text); // One deadline covers token acquisition and the request, so the fail mode applies within Options.Timeout, // before an agent-hooks interceptor timeout would. @@ -204,7 +233,7 @@ public PurviewDlpClient( return Failure(activity, correlationId, sessionId, "entra token timeout", null, started); } - var body = BuildBody(activity, data, truncated, agent, applicationId, sessionId, sequence); + var body = BuildBody(activity, data, truncated, agent, applicationId, sessionId, sequence, correlationId); return await PostAsync(activity, body, token, correlationId, sessionId, truncated, started, deadline.Token, cancellationToken).ConfigureAwait(false); } @@ -271,7 +300,8 @@ private static void ObserveFailure(Task task) => /// /// The processContent request: one conversation entry with the text, the agent, the session and its - /// sequence, and the application the DLP policies are scoped to. + /// sequence, and the application the DLP policies are scoped to. The entry's identifier is the call's + /// client-request-id. /// private JsonObject BuildBody( PurviewDlpActivity activity, @@ -280,11 +310,12 @@ private JsonObject BuildBody( PurviewDlpAgentContext agent, string applicationId, string sessionId, - long sequence) + long sequence, + string correlationId) { // Graph reports an entry without a name as a permanent BadRequest in processingErrors, with HTTP 200 and no - // policy actions, so the name is never empty. - var agentName = ReplaceLoneSurrogates(FirstNonEmpty(agent.AgentName) ?? DefaultAgentName); + // policy actions, so the name is never empty: it defaults to the agent identity, then the application. + var agentName = ReplaceLoneSurrogates(FirstNonEmpty(agent.AgentName, agent.AgentId) ?? applicationId); var activityName = ActivityName(activity); var timestamp = _timeProvider.GetUtcNow().UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); var agentInfo = new JsonObject { ["@odata.type"] = "microsoft.graph.aiAgentInfo" }; @@ -304,7 +335,7 @@ private JsonObject BuildBody( ["contentEntries"] = new JsonArray(new JsonObject { ["@odata.type"] = "microsoft.graph.processConversationMetadata", - ["identifier"] = _idFactory().ToString(), + ["identifier"] = correlationId, ["content"] = new JsonObject { ["@odata.type"] = "microsoft.graph.textContent", @@ -499,51 +530,55 @@ private PurviewDlpEvaluationResult Interpret( } /// - /// The policy actions. Any action restricted with block blocks, whatever shape the other actions have; - /// is set when an action is not an object whose restrictionAction, when - /// present, is a string. + /// The policy actions. An action blocks when its restrictionAction is block or its action is + /// blockAccess, in any case, whatever shape the rest of it or the other actions have; + /// is set when an action is not an object whose action and + /// restrictionAction, when present, are strings. /// private static PurviewDlpDecision ReadDecision(JsonArray actions, out bool malformed) { malformed = false; - string? blocking = null; + var blocks = false; + string? blockingRestriction = null; foreach (var entry in actions) { - if (!TryReadRestriction(entry, out var restriction)) - { - malformed = true; - } - else if (blocking == null && restriction != null && restriction.Equals("block", StringComparison.OrdinalIgnoreCase)) + malformed |= !TryReadAction(entry, out var action, out var restriction); + if (!blocks && (Matches(restriction, "block") || Matches(action, "blockAccess"))) { - blocking = restriction; + blocks = true; + blockingRestriction = restriction; } } return new PurviewDlpDecision { - BlockAction = blocking != null, - RestrictionAction = blocking, + BlockAction = blocks, + RestrictionAction = blockingRestriction, ActionCount = actions.Count, }; } /// - /// An action's restrictionAction, and whether the action has the expected shape: an object whose - /// restrictionAction, when present, is a string, and whose properties each appear once. + /// An action's action and restrictionAction, each when it is a string, and whether the action has + /// the expected shape: an object whose action and restrictionAction, when present, are strings, + /// and whose properties each appear once. /// - private static bool TryReadRestriction(JsonNode? entry, out string? restriction) + private static bool TryReadAction(JsonNode? entry, out string? action, out string? restriction) { + action = null; restriction = null; - if (entry is not JsonObject action) + if (entry is not JsonObject value) { return false; } try { - var value = action["restrictionAction"]; - restriction = ReadString(value); - return value == null || restriction != null; + var actionNode = value["action"]; + var restrictionNode = value["restrictionAction"]; + action = ReadString(actionNode); + restriction = ReadString(restrictionNode); + return (actionNode == null || action != null) && (restrictionNode == null || restriction != null); } catch (ArgumentException) { @@ -551,6 +586,9 @@ private static bool TryReadRestriction(JsonNode? entry, out string? restriction) } } + private static bool Matches(string? value, string expected) => + value != null && value.Equals(expected, StringComparison.OrdinalIgnoreCase); + /// The processing errors as a count, with the codes and error types that look like identifiers. private static string ProcessingErrors(JsonArray errors) { diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs index e4d9b24f..5f1a3ac9 100644 --- a/src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs @@ -18,10 +18,13 @@ public enum PurviewDlpActivity /// The policy actions Purview returned for an evaluation. public sealed class PurviewDlpDecision { - /// Whether a policy action's restrictionAction is block. + /// + /// Whether a policy action blocks: its restrictionAction is block or its action is + /// blockAccess. + /// public bool BlockAction { get; init; } - /// The restrictionAction of the blocking action, when one blocked. + /// The restrictionAction of the blocking action, when it has one. public string? RestrictionAction { get; init; } /// How many policy actions Purview returned, blocking or not (for example audit or warn). diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs index b61c0902..87b925e8 100644 --- a/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs @@ -11,10 +11,11 @@ namespace Microsoft.Agents.A365.Tooling.Protection.Purview /// Resolves the Microsoft Graph token for a Purview evaluation, and whose behalf the content is evaluated on. /// /// - /// The client does not cache what the resolver returns: the resolver owns caching. The resolvers in - /// use the agent's connection, whose MSAL implementation caches every token - /// of the agentic chain, or a host-supplied provider, which should cache its tokens. The resolver is called within - /// the evaluation's deadline (). + /// The client does not cache what the resolver returns: the resolver owns caching. + /// caches the agentic user's tokens; + /// never caches, since its tokens may be for a user the + /// agent context does not identify. The resolver is called within the evaluation's deadline + /// (), on the thread pool. /// /// The agent and turn being evaluated. /// The scope to request, . @@ -88,7 +89,10 @@ public sealed class PurviewDlpAgentContext /// public string? ApplicationId { get; set; } - /// The agent's display name, which names the app and the content in Purview; defaults to agent365-agent. + /// + /// The agent's display name, which names the app and the content in Purview; defaults to , + /// then the application id, since Purview needs a name. + /// public string? AgentName { get; set; } /// diff --git a/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs index 2351ece3..7a63b8c9 100644 --- a/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs @@ -12,32 +12,56 @@ namespace Microsoft.Agents.A365.Tooling.Protection.Purview /// Token resolvers for . public static class PurviewDlpTokenResolvers { + /// How long one agentic user token acquisition may take, shared by the evaluations waiting for it. + internal static readonly TimeSpan AcquisitionTimeout = TimeSpan.FromSeconds(30); + /// /// The agentic user's delegated Microsoft Graph token, issued through the agent's Agents SDK connection, so /// content is evaluated as the agent's agentic user (/me). The connection's blueprint credential issues the /// agent identity's assertion and exchanges it for the agentic user's token - /// (); MSAL connections cache each token of that - /// chain. The token needs the delegated permission Content.Process.User, which agentic users inherit - /// from their blueprint's Microsoft Graph grant. + /// (). The token needs the delegated permission + /// Content.Process.User, which agentic users inherit from their blueprint's Microsoft Graph grant. /// /// + /// /// The evaluation's must carry , /// and , for /// example from the incoming activity: GetAgenticTenantId(), GetAgenticInstanceId() and /// GetAgenticUser(). Without them the resolver throws, and the evaluation follows the fail mode. + /// + /// + /// The resolver caches each token per tenant, agent identity, agentic user and scope until it expires, and + /// refreshes it in the background within five minutes of expiry, keeping the cached token while it is still + /// valid, so connections that do not cache are not asked on every evaluation. Concurrent evaluations share one + /// acquisition, bounded by thirty seconds rather than any evaluation's deadline, so a slow first acquisition + /// still fills the cache; failures are never cached. + /// /// /// /// The agent's connection, for example connections.GetDefaultConnection() cast to /// (MSAL connections implement it). /// /// A resolver for . - public static PurviewDlpTokenResolver FromAgenticUser(IAgenticTokenProvider connection) + public static PurviewDlpTokenResolver FromAgenticUser(IAgenticTokenProvider connection) => + CreateAgenticUserResolver(connection, TimeProvider.System, AcquisitionTimeout); + + /// The agentic user resolver with the given clock and acquisition timeout; for tests. + internal static PurviewDlpTokenResolver CreateAgenticUserResolver( + IAgenticTokenProvider connection, + TimeProvider timeProvider, + TimeSpan acquisitionTimeout) { if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + var cache = new AgenticUserTokenCache( + async (key, cancellationToken) => await connection + .GetAgenticUserTokenAsync(key.TenantId, key.AgentId, key.AgenticUserId, new List { key.Scope }, cancellationToken) + .ConfigureAwait(false), + timeProvider, + acquisitionTimeout); return async (agent, scope, cancellationToken) => { if (agent == null) @@ -45,13 +69,13 @@ public static PurviewDlpTokenResolver FromAgenticUser(IAgenticTokenProvider conn throw new ArgumentNullException(nameof(agent)); } - var tenantId = Require(agent.TenantId, nameof(PurviewDlpAgentContext.TenantId)); - var agentId = Require(agent.AgentId, nameof(PurviewDlpAgentContext.AgentId)); - var agenticUserId = Require(agent.AgenticUserId, nameof(PurviewDlpAgentContext.AgenticUserId)); - var token = await connection - .GetAgenticUserTokenAsync(tenantId, agentId, agenticUserId, new List { scope }, cancellationToken) - .ConfigureAwait(false); - return string.IsNullOrWhiteSpace(token) ? null : new PurviewDlpToken(token); + var key = new AgenticUserTokenKey( + Require(agent.TenantId, nameof(PurviewDlpAgentContext.TenantId)), + Require(agent.AgentId, nameof(PurviewDlpAgentContext.AgentId)), + Require(agent.AgenticUserId, nameof(PurviewDlpAgentContext.AgenticUserId)), + scope); + var token = await cache.GetAsync(key, cancellationToken).ConfigureAwait(false); + return string.IsNullOrWhiteSpace(token) ? null : new PurviewDlpToken(token!); }; } @@ -59,8 +83,9 @@ public static PurviewDlpTokenResolver FromAgenticUser(IAgenticTokenProvider conn /// A Microsoft Graph token from the host's own token provider, for example an on-behalf-of token for the /// signed-in user, which evaluates as /me. With , content is evaluated for that /// user (/users/{userId}), as an app-only token with the application permission - /// Content.Process.All requires; that path has not been validated end to end yet. The provider should - /// cache its tokens: the client asks for one on every evaluation. + /// Content.Process.All requires; that path has not been validated end to end yet. Its tokens are never + /// cached here, since they may be for a user the agent context does not identify: the provider should cache + /// them, as the client asks for one on every evaluation. /// /// Returns an access token for the given scope; it observes the cancellation token. /// The user to evaluate for, or null for the token's own user. diff --git a/src/Tooling/Core/docs/design.md b/src/Tooling/Core/docs/design.md index 6c489f0a..511ced04 100644 --- a/src/Tooling/Core/docs/design.md +++ b/src/Tooling/Core/docs/design.md @@ -390,7 +390,8 @@ src/Tooling/Core/ │ ├── PurviewDlpOptions.cs # Configuration (environment variables), response mode │ ├── PurviewDlpEvaluationResult.cs # Activity, policy actions and evaluation result │ ├── PurviewDlpTokenResolver.cs # Token resolver delegate, token, agent context -│ └── PurviewDlpTokenResolvers.cs # Agentic user and host-supplied tokens +│ ├── PurviewDlpTokenResolvers.cs # Agentic user and host-supplied tokens +│ └── AgenticUserTokenCache.cs # Agentic user token cache (internal) ├── Constants.cs # Constants ├── Utility.cs # Helper methods ├── Microsoft.Agents.A365.Tooling.csproj @@ -521,8 +522,9 @@ if (result is { Allowed: false }) `PurviewDlpClient` sends a text to the Microsoft Graph `processContent` API, which applies the Purview DLP policies scoped to the agent's application and writes the Purview audit record: `UploadText` for content entering the agent, -such as the user's prompt, and `DownloadText` for its reply. A policy action restricted with `block` blocks. The -agentic user's delegated token (`Content.Process.User`) evaluates as `/me`. Agent-hooks hosts register it through +such as the user's prompt, and `DownloadText` for its reply. A policy action restricted with `block`, or whose action +is `blockAccess`, blocks. The agentic user's delegated token (`Content.Process.User`) evaluates as `/me`. Agent-hooks +hosts register it through `A365PurviewInterceptor` in `Microsoft.Agents.A365.Tooling.Extensions.AgentHooks` (see its [README](../../Extensions/AgentHooks/README.md)). diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 5c5e333c..34890a1c 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -224,13 +224,14 @@ public static class A365AgentHooks /// emitter and a deny from either wins. /// /// - /// Per-interceptor timeout; defaults to the longest timeout of the given Defender and Purview options plus two - /// seconds (ten seconds plus two when neither is given). Each client evaluates within one deadline, its - /// options' Timeout, that covers token acquisition and the request, so its fail mode applies before this - /// timeout turns into an agent-hooks host error. + /// Per-interceptor timeout; defaults to the longest timeout of the given Defender options and, when Purview DLP + /// is enabled, Purview options, plus two seconds (ten seconds plus two when neither counts), so an emitter for + /// Defender alone is sized as before. Each client evaluates within one deadline, its options' Timeout, + /// that covers token acquisition and the request, so its fail mode applies before this timeout turns into an + /// agent-hooks host error. /// /// The Defender options, whose timeout sets the default. - /// The Purview options, whose timeout sets the default. + /// The Purview options, whose timeout sets the default when Purview DLP is enabled. /// The configured emitter. public static InterceptionEmitter CreateProtectionEmitter( TimeSpan? interceptorTimeout = null, @@ -238,7 +239,7 @@ public static InterceptionEmitter CreateProtectionEmitter( PurviewDlpOptions? purview = null) { var slowest = defender?.Timeout; - if (purview != null && (slowest == null || purview.Timeout > slowest)) + if (purview is { Enabled: true } && (slowest == null || purview.Timeout > slowest)) { slowest = purview.Timeout; } diff --git a/src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs index 309776c2..46be6e56 100644 --- a/src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs @@ -34,9 +34,12 @@ public sealed record A365PurviewCall(PurviewDlpAgentContext Agent, PurviewDlpTok /// /// /// - /// The text is the content's string, or every string and number in structured content, one per line. Empty content - /// is allowed without a call. Each evaluation uses the context's session id as the content's - /// correlationId and its sequence as sequenceNumber. + /// The text is the content's string, or every string and number in structured content, one per line, read within + /// . Content without text is allowed without a call, unless + /// reading stopped at the limit before any text was found: then the verdict follows the fail mode + /// (). Text cut at the limit is sent as a truncated copy. Each + /// evaluation uses the context's session id as the content's correlationId and its sequence as + /// sequenceNumber. /// /// /// When no verdict is obtained (no agent identity, identity resolution, token, transport or HTTP failure, or @@ -120,18 +123,36 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio return new Verdict(Decision.Allow); } - // Content without text has nothing for Purview to inspect. - var text = ContentText(content); + var sessionId = ReadString((context.Json["session"] as JsonObject)?["id"]); + var sequence = ReadSequence(context.Json["sequence"]); + var audit = activity == PurviewDlpActivity.DownloadText && _client.Options.ResponseMode == PurviewDlpResponseMode.Audit; + + string text; + bool cut; + try + { + text = ContentText(content, _client.Options.MaxContentCharacters, out cut); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Content that cannot be read, for example a parsed object whose property appears twice, is not + // skipped silently: like any missing verdict, it follows the fail mode. + _logger?.LogWarning(ex, "The content at {InterceptionPoint} could not be read; the fail mode applies.", point); + return Decide(audit, _client.Unavailable(activity, $"content could not be read ({ex.GetType().Name})", sessionId)); + } + if (string.IsNullOrWhiteSpace(text)) { - return new Verdict(Decision.Allow); + // Content without text has nothing for Purview to inspect, unless reading stopped at the limit before any + // text was found: what was not read could hold some. + return cut + ? Decide(audit, _client.Unavailable(activity, PurviewDlpClient.UnreadContentError, sessionId)) + : new Verdict(Decision.Allow); } - var sessionId = ReadString((context.Json["session"] as JsonObject)?["id"]); - var sequence = ReadSequence(context.Json["sequence"]); - if (activity == PurviewDlpActivity.DownloadText && _client.Options.ResponseMode == PurviewDlpResponseMode.Audit) + if (audit) { - AuditInBackground(context, point, text, sessionId, sequence); + AuditInBackground(context, point, text, cut, sessionId, sequence); return new Verdict(Decision.Allow); } @@ -141,7 +162,7 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio // Without an agent identity Purview cannot be called; like any missing verdict, that follows the fail // mode rather than allowing silently. result = ResolveCall(context, sessionId, sequence) is { } call - ? await _client.EvaluateAsync(activity, text, call.Agent, call.TokenResolver, cancellationToken).ConfigureAwait(false) + ? await _client.EvaluateTextAsync(activity, text, cut, call.Agent, call.TokenResolver, cancellationToken).ConfigureAwait(false) : _client.Unavailable(activity, NoIdentity, sessionId); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) @@ -202,63 +223,109 @@ public static Verdict ToVerdict(PurviewDlpEvaluationResult result) } /// - /// The text Purview inspects in input or output content: a string as it is; otherwise every string and number in - /// the content, in document order, one per line. Property names are structure, not content, and are left out. - /// The walk keeps its own stack, so content of any depth is read in full. + /// The text Purview inspects in input or output content: a string as it is (the client cuts a long one); + /// otherwise every string and number in the content, in document order, one per line, with property names left + /// out as structure. Reading structured content is bounded: at most values and + /// containers, and one character more of text than the limit, so neither long text nor a large structure of + /// empty values is read past it; says that reading stopped early. The walk keeps its own + /// stack, so deep content cannot exhaust the call stack. /// - internal static string ContentText(JsonNode? content) + internal static string ContentText(JsonNode? content, int maxCharacters, out bool cut) { + cut = false; if (content is JsonValue single && single.TryGetValue(out var only)) { return only; } + // One character past the limit tells the client the text is longer than it may send. + var textLimit = (int)Math.Min(int.MaxValue, (long)maxCharacters + 1); + var nodes = 0; var builder = new StringBuilder(); - var pending = new Stack(); - pending.Push(content); - while (pending.Count > 0) + var pending = new Stack>(); + try { - switch (pending.Pop()) + var node = content; + while (true) { - case JsonArray array: - for (var index = array.Count - 1; index >= 0; index--) - { - pending.Push(array[index]); - } - - break; - case JsonObject obj: - foreach (var property in obj.Reverse()) - { - pending.Push(property.Value); - } - + if (++nodes > maxCharacters) + { + cut = true; break; - case JsonValue value when value.TryGetValue(out var text): - AppendLine(builder, text); - break; - case JsonValue value when value.GetValueKind() == JsonValueKind.Number: - AppendLine(builder, value.ToJsonString()); + } + + switch (node) + { + case JsonArray array: + pending.Push(array.GetEnumerator()); + break; + case JsonObject obj: + pending.Push(obj.Select(property => property.Value).GetEnumerator()); + break; + case JsonValue value when value.TryGetValue(out var text): + cut = !AppendLine(builder, text, textLimit); + break; + case JsonValue value when value.GetValueKind() == JsonValueKind.Number: + cut = !AppendLine(builder, value.ToJsonString(), textLimit); + break; + } + + // The next node is the next child of the innermost container that has one. + while (!cut && pending.Count > 0 && !pending.Peek().MoveNext()) + { + pending.Pop().Dispose(); + } + + if (cut || pending.Count == 0) + { break; + } + + node = pending.Peek().Current; + } + } + finally + { + while (pending.Count > 0) + { + pending.Pop().Dispose(); } } return builder.ToString(); } - private static void AppendLine(StringBuilder builder, string text) + /// Appends a line of text while the text stays within the limit; false when it was cut. + private static bool AppendLine(StringBuilder builder, string text, int limit) { if (text.Length == 0) { - return; + return true; } if (builder.Length > 0) { + if (builder.Length >= limit) + { + return false; + } + builder.Append('\n'); } - builder.Append(text); + var kept = Math.Min(limit - builder.Length, text.Length); + builder.Append(text, 0, kept); + return kept == text.Length; + } + + /// + /// The verdict for a result decided without a call: it goes to the callback, and a reply audited in the + /// background is never blocked. + /// + private Verdict Decide(bool audit, PurviewDlpEvaluationResult result) + { + Report(result); + return audit ? new Verdict(Decision.Allow) : ToVerdict(result); } /// @@ -266,7 +333,7 @@ private static void AppendLine(StringBuilder builder, string text) /// is at hand, and the evaluation runs on the thread pool, bounded by the client's own deadline rather than the /// emitter's token. Its outcome, or a failure, goes only to the callback. /// - private void AuditInBackground(AgentContext context, string point, string text, string? sessionId, long? sequence) + private void AuditInBackground(AgentContext context, string point, string text, bool cut, string? sessionId, long? sequence) { (PurviewDlpAgentContext Agent, PurviewDlpTokenResolver TokenResolver)? call; try @@ -292,7 +359,7 @@ private void AuditInBackground(AgentContext context, string point, string text, try { result = await _client - .EvaluateAsync(PurviewDlpActivity.DownloadText, text, resolved.Agent, resolved.TokenResolver, CancellationToken.None) + .EvaluateTextAsync(PurviewDlpActivity.DownloadText, text, cut, resolved.Agent, resolved.TokenResolver, CancellationToken.None) .ConfigureAwait(false); } catch (Exception ex) diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 1244514a..da55ffb6 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -4,9 +4,9 @@ Microsoft Agent 365 real-time protection on the [agent-hooks](https://github.com control contract (AGENT-HOOKS-0.1), using the .NET package [`ResponsibleAI.AgentHooks`](https://www.nuget.org/packages/ResponsibleAI.AgentHooks). -`A365DefenderInterceptor` is an agent-hooks interceptor for Microsoft Defender for AI, and `A365PurviewInterceptor` -one for Microsoft Purview data loss prevention (DLP) and audit (see [Microsoft Purview DLP](#microsoft-purview-dlp)). -Both register on one emitter, where a deny from either wins. +`A365DefenderInterceptor` is an agent-hooks interceptor for Microsoft Defender for AI. `A365PurviewInterceptor` is an +agent-hooks interceptor for Microsoft Purview data loss prevention (DLP) and audit (see +[Microsoft Purview DLP](#microsoft-purview-dlp)). Both register on one emitter, where a deny from either wins. Each context the host emits at the four points Defender evaluates is sent to the prevention endpoint (`POST .../v1/protection/evaluate`) as a fitted copy: the client builds a copy of the context that meets @@ -104,11 +104,11 @@ Agents built on Microsoft Agent Framework can register the same interceptor with **Interceptor timeout.** The client evaluates within one deadline, `DefenderRtpOptions.Timeout` (default 10 s), that covers token acquisition and the request, and applies the fail mode when it passes. The emitter's per-interceptor timeout must be longer: when it fires first, agent-hooks records a fail-closed host error even -if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` sets it to the longest timeout of the -options it is given (`DefenderRtpOptions.Timeout`, `PurviewDlpOptions.Timeout`) plus two seconds. If you register the -interceptor on another emitter (for example -`new InterceptionEmitter()`, whose default is 5 s, or through Agent Framework), set its interceptor timeout above -`DefenderRtpOptions.Timeout`, or lower `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS`. +if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` sets it to the longest of +`DefenderRtpOptions.Timeout` and, when Purview DLP is enabled, `PurviewDlpOptions.Timeout`, plus two seconds. If you +register the interceptor on another emitter (for example `new InterceptionEmitter()`, whose default is 5 s, or +through Agent Framework), set its interceptor timeout above `DefenderRtpOptions.Timeout`, or lower +`A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS`. ## Configuration @@ -157,18 +157,21 @@ and Data Security Posture Management (DSPM) for AI show. | `output` | `downloadText`: the reply | audit: sent without waiting, never blocked | with `enforce`, the reply is replaced | Other points are allowed without a call; tool calls and tool results are not sent to Purview. The text sent is the -content's string, or every string and number in structured content, one per line; content without text is allowed -without a call. Each call is one conversation entry: the context's `session.id` is its `correlationId`, its -`sequence` the entry's `sequenceNumber`, and the entry names the agent (`agents`: the blueprint id, the agent -identity id and the agent's name). Policies match on the entry's application -(`protectedAppMetadata.applicationLocation`), `PurviewDlpAgentContext.ApplicationId`, which defaults to the blueprint -id, then the agent identity id. - -A policy action whose `restrictionAction` is `block` blocks: the verdict denies with reason `purview:block`, the -message "The request was blocked by a Microsoft Purview data loss prevention policy." and the evidence -`urn:a365:purview:`. Other actions (for example audit) allow and are counted in -`PurviewDlpEvaluationResult.Decision`. Every call sends a new `client-request-id`, returned as -`PurviewDlpEvaluationResult.CorrelationId`; Graph logs the call under it. +content's string, or every string and number in structured content, one per line. Structured content is read only up +to `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS`, in values and containers as well as in characters, and text cut there is +sent as a truncated copy. Content without text is allowed without a call, unless reading stopped at the limit before +any text was found: what was not read could hold some, so the fail mode applies (`UnreadContentError`). Each call is +one conversation entry, identified by the call's `client-request-id`: the context's `session.id` is its +`correlationId`, its `sequence` the entry's `sequenceNumber`, and the entry names the agent (`agents`: the blueprint +id when known, the agent identity id and the agent's name, which defaults to the agent identity id). Policies match on +the entry's application (`protectedAppMetadata.applicationLocation`), `PurviewDlpAgentContext.ApplicationId`, which +defaults to the blueprint id, then the agent identity id. + +A policy action whose `restrictionAction` is `block` or whose `action` is `blockAccess` (in any case) blocks: the +verdict denies with reason `purview:block`, the message "The request was blocked by a Microsoft Purview data loss +prevention policy." ("The response was ..." for a reply) and the evidence `urn:a365:purview:`. +Other actions (for example audit) allow and are counted in `PurviewDlpEvaluationResult.Decision`. Every call sends a +new `client-request-id`, returned as `PurviewDlpEvaluationResult.CorrelationId`; Graph logs the call under it. **Replies.** Purview DLP policies for custom AI apps restrict prompts (`uploadText`) and cannot block replies. By default (`A365_PURVIEW_DLP_RESPONSE_MODE=audit`) the interceptor allows the reply at once, whatever the fail mode, and @@ -195,12 +198,16 @@ Lone UTF-16 surrogates are replaced with U+FFFD. (`IAgenticTokenProvider.GetAgenticUserTokenAsync`) for the agentic user's delegated Microsoft Graph token, which carries `Content.Process.User`, and evaluates as `/me`: the agent's agentic user. It needs the turn's tenant, agent identity and agentic user, from the incoming activity: `GetAgenticTenantId()`, `GetAgenticInstanceId()` and -`GetAgenticUser()`. MSAL connections cache each token of the agentic chain, so the client does not cache tokens itself. +`GetAgenticUser()`. It caches each token per tenant, agent identity, agentic user and scope until it expires, and +refreshes it in the background within five minutes of expiry while the cached token stays in use, so a connection that +does not cache is not asked on every evaluation. Concurrent evaluations share one acquisition, bounded by thirty +seconds rather than any evaluation's deadline, and failures are never cached. `PurviewDlpTokenResolvers.FromAccessTokenProvider` uses a Microsoft Graph token from the host instead, for example an on-behalf-of token for the signed-in user (`/me`), or an app-only token with the application permission `Content.Process.All` for a given user (`/users/{userId}`). The app-only path has not been validated end to end yet. -The provider should cache its tokens, since the client asks for one on every evaluation. +Its tokens are never cached by the SDK, since they may be for a user the agent context does not identify; the provider +should cache them, as the client asks for one on every evaluation. `protectionScopes/compute` is not called: `processContent` applies the policies itself, and computing scopes needs another permission (`ProtectionScopes.Compute.User`). @@ -272,7 +279,7 @@ var emitter = A365AgentHooks.CreateProtectionEmitter(defender: defender.Options, The factory, like Defender's, is invoked only when Purview DLP is enabled, at `input` and `output`, for content with text. When it returns null or throws, or resolving the token fails, the verdict follows the fail mode (except for a reply audited in the background). `A365AgentHooks.CreateProtectionEmitter` sizes the emitter's per-interceptor timeout -to the longer of the two clients' timeouts plus two seconds. +to the longer of Defender's timeout and, when Purview DLP is enabled, Purview's, plus two seconds. ### Purview configuration @@ -283,7 +290,7 @@ to the longer of the two clients' timeouts plus two seconds. | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | the token scope (default `https://graph.microsoft.com/.default`) | | `A365_PURVIEW_DLP_FAIL_MODE` | `open` (default) or `closed`, which blocks when no verdict is obtained; any other value is rejected | | `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | the deadline for one evaluation, including token acquisition (default 10000) | -| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | the most text sent in one evaluation (default 100000). See **Content size** | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | the most text sent in one evaluation, and the most values read from structured content (default 100000). See **Content size** | | `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` (default) or `enforce`, which waits for Purview's verdict on replies; any other value is rejected | ### Purview limitations