diff --git a/CHANGELOG.md b/CHANGELOG.md index b331d665..2afa85a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,35 @@ 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`, 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`), 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 + 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`. 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/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..2ffde280 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpClientTests.cs @@ -0,0 +1,1006 @@ +// 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; + entry["identifier"]!.GetValue().Should().Be(call.ClientRequestId, "one id per call identifies the request and its content entry"); + 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, 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($"{expected} uploadText", "Graph rejects an entry without a name"); + body["contentToProcess"]!["protectedAppMetadata"]!["name"]!.GetValue().Should().Be(expected); + } + + [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 }); + } + + [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() + { + 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":[{"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")] + [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"); + } + + [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] + 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..9c8bf91e --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/PurviewDlpTokenResolversTests.cs @@ -0,0 +1,358 @@ +// 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" })), + It.IsAny()), + 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(); + } + + [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(" ")] + 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..06c5c817 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365PurviewInterceptorTests.cs @@ -0,0 +1,838 @@ +// 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 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)] + 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 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() + { + 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); + } + } + + /// 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; + 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/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 new file mode 100644 index 00000000..a35679ea --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpClient.cs @@ -0,0 +1,767 @@ +// 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, 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 + { + /// 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 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"; + 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 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) + { + 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)); + } + + var blank = string.IsNullOrWhiteSpace(text); + if (blank && !truncatedCopy) + { + 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(); + + // 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 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. + 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, correlationId); + 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. The entry's identifier is the call's + /// client-request-id. + /// + private JsonObject BuildBody( + PurviewDlpActivity activity, + string data, + bool truncated, + PurviewDlpAgentContext agent, + string applicationId, + string sessionId, + 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: 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" }; + 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"] = correlationId, + ["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. 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; + var blocks = false; + string? blockingRestriction = null; + foreach (var entry in actions) + { + malformed |= !TryReadAction(entry, out var action, out var restriction); + if (!blocks && (Matches(restriction, "block") || Matches(action, "blockAccess"))) + { + blocks = true; + blockingRestriction = restriction; + } + } + + return new PurviewDlpDecision + { + BlockAction = blocks, + RestrictionAction = blockingRestriction, + ActionCount = actions.Count, + }; + } + + /// + /// 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 TryReadAction(JsonNode? entry, out string? action, out string? restriction) + { + action = null; + restriction = null; + if (entry is not JsonObject value) + { + return false; + } + + try + { + 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) + { + return false; + } + } + + 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) + { + 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..5f1a3ac9 --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpEvaluationResult.cs @@ -0,0 +1,88 @@ +// 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 blocks: its restrictionAction is block or its action is + /// blockAccess. + /// + public bool BlockAction { get; init; } + + /// 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). + 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..87b925e8 --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolver.cs @@ -0,0 +1,126 @@ +// 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. + /// 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, . + /// 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 , + /// then the application id, since Purview needs a name. + /// + 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..7a63b8c9 --- /dev/null +++ b/src/Tooling/Core/Protection/Purview/PurviewDlpTokenResolvers.cs @@ -0,0 +1,120 @@ +// 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 + { + /// 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 + /// (). 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) => + 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) + { + throw new ArgumentNullException(nameof(agent)); + } + + 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!); + }; + } + + /// + /// 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. 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. + /// 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..511ced04 100644 --- a/src/Tooling/Core/docs/design.md +++ b/src/Tooling/Core/docs/design.md @@ -379,12 +379,19 @@ 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 +│ └── AgenticUserTokenCache.cs # Agentic user token cache (internal) ├── Constants.cs # Constants ├── Utility.cs # Helper methods ├── Microsoft.Agents.A365.Tooling.csproj @@ -511,6 +518,41 @@ 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`, 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)). + +```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..34890a1c 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,36 @@ 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 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 Defender 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, DefenderRtpOptions? defender = null) => - new InterceptionEmitter( + public static InterceptionEmitter CreateProtectionEmitter( + TimeSpan? interceptorTimeout = null, + DefenderRtpOptions? defender = null, + PurviewDlpOptions? purview = null) + { + var slowest = defender?.Timeout; + if (purview is { Enabled: true } && (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 +264,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..46be6e56 --- /dev/null +++ b/src/Tooling/Extensions/AgentHooks/A365PurviewInterceptor.cs @@ -0,0 +1,430 @@ +// 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, 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 + /// 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); + } + + 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)) + { + // 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); + } + + if (audit) + { + AuditInBackground(context, point, text, cut, 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.EvaluateTextAsync(activity, text, cut, 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 (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, 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>(); + try + { + var node = content; + while (true) + { + if (++nodes > maxCharacters) + { + cut = true; + break; + } + + 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(); + } + + /// 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 true; + } + + if (builder.Length > 0) + { + if (builder.Length >= limit) + { + return false; + } + + builder.Append('\n'); + } + + 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); + } + + /// + /// 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, bool cut, 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 + .EvaluateTextAsync(PurviewDlpActivity.DownloadText, text, cut, 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..da55ffb6 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. `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 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,10 +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 -`DefenderRtpOptions.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 @@ -140,5 +144,161 @@ 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. 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 +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()`. 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. +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`). + +### 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 Defender's timeout and, when Purview DLP is enabled, Purview's, 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, 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 + +- 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.