From e1a17f026d1f0bd409ff8eeed44d9142194933a3 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala <12496535+DheerajPannala@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:01:16 +0100 Subject: [PATCH 1/6] Add Microsoft Purview DLP real-time protection on agent-hooks PurviewDlpClient (@microsoft/agents-a365-tooling) sends the text of a prompt (uploadText) or a reply (downloadText) to the Microsoft Graph processContent API with a new client-request-id, and returns whether the content may proceed: a policy action with restrictionAction block or action blockAccess blocks; processing errors, non-2xx statuses, timeouts, transport errors and responses of another shape follow A365_PURVIEW_DLP_FAIL_MODE. Text over the limit is sent with isTruncated, and an allow of it follows the fail mode. Errors carry the failure or the exception type only. PurviewDlpTokenResolvers.fromAgenticUser uses the agentic user's delegated Microsoft Graph token from the Agents SDK connection (/me), cached per tenant, agent, agentic user and scope; fromAccessTokenProvider uses a host token, for /me or /users/{id}, and is never cached. A365PurviewInterceptor (@microsoft/agents-a365-tooling-extensions-agenthooks) evaluates input as uploadText and output as downloadText (audited in the background by default, awaited in enforce mode), and composes with the Defender interceptor on one emitter. createProtectionEmitter covers the Purview timeout when Purview DLP is enabled; addA365Purview registers it. Ports Dominik Bezic's Purview POC (users/dominikbezic/purview-poc). Co-authored-by: Dominik Bezic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc2218e-29c5-4011-8ea5-e36163d58d4c --- CHANGELOG.md | 36 + CLAUDE.md | 12 +- README.md | 2 +- docs/design.md | 6 +- .../README.md | 200 +++- .../docs/design.md | 147 ++- .../package.json | 4 +- .../src/A365AgentHooks.ts | 71 +- .../src/A365PurviewInterceptor.ts | 387 +++++++ .../src/index.ts | 1 + packages/agents-a365-tooling/README.md | 4 + packages/agents-a365-tooling/docs/design.md | 96 +- .../src/configuration/ToolingConfiguration.ts | 138 +++ .../ToolingConfigurationOptions.ts | 36 + packages/agents-a365-tooling/src/index.ts | 1 + .../src/purview/PurviewDlpClient.ts | 718 +++++++++++++ .../src/purview/PurviewDlpTokenResolvers.ts | 98 ++ .../src/purview/contracts.ts | 154 +++ .../agents-a365-tooling/src/purview/index.ts | 6 + .../src/purview/internal.ts | 187 ++++ .../A365PurviewInterceptor.test.ts | 788 +++++++++++++++ .../PurviewDlpConfiguration.test.ts | 176 ++++ tests/tooling/fixtures/purview.ts | 107 ++ tests/tooling/purview-dlp-client.test.ts | 945 ++++++++++++++++++ 24 files changed, 4254 insertions(+), 66 deletions(-) create mode 100644 packages/agents-a365-tooling-extensions-agenthooks/src/A365PurviewInterceptor.ts create mode 100644 packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts create mode 100644 packages/agents-a365-tooling/src/purview/PurviewDlpTokenResolvers.ts create mode 100644 packages/agents-a365-tooling/src/purview/contracts.ts create mode 100644 packages/agents-a365-tooling/src/purview/index.ts create mode 100644 packages/agents-a365-tooling/src/purview/internal.ts create mode 100644 tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts create mode 100644 tests/tooling/configuration/PurviewDlpConfiguration.test.ts create mode 100644 tests/tooling/fixtures/purview.ts create mode 100644 tests/tooling/purview-dlp-client.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aeed24e9..00830ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cannot be verified (no verdict, no agent identity, or an allow of truncated content) follow the fail mode. Requires Node.js 20 or later. +### Added (`@microsoft/agents-a365-tooling`, Purview) + +- **Microsoft Purview data loss prevention client (opt-in)** - `PurviewDlpClient.evaluate` sends + the text of a prompt (`uploadText`) or a reply (`downloadText`) to the Microsoft Graph + `processContent` API (`/me/dataSecurityAndGovernance/processContent`, or `/users/{id}/...`), which + applies the tenant's DLP policies for the agent's application and records the interaction for + audit. A policy action with `restrictionAction: block` or `action: blockAccess` blocks the content; other actions allow it + and are counted. The request is one conversation message with a non-empty name, the conversation's + `correlationId` and `sequenceNumber`, the agent (`agents[0]` with its blueprint) and the blueprint's + application id as the `applicationLocation`; every call sends a new `client-request-id`. +- `PurviewDlpTokenResolvers.fromAgenticUser` uses the agentic user's delegated Microsoft Graph token + from the Agents SDK connection (`getAgenticUserToken`, needs `Content.Process.User`), cached per + tenant, agent, agentic user and scope; `fromAccessTokenProvider` uses a host's token, for `/me` or + for a named user. The Graph base URL must be `https`, and the request does not follow a redirect. +- Processing errors (reported with HTTP 200), non-2xx statuses, timeouts, transport errors and + responses of another shape follow `A365_PURVIEW_DLP_FAIL_MODE` (fail open by default); errors name + the failure or exception type only, never a response body or token. Text longer than + `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` (default 100000) is sent with `isTruncated: true`, and + Purview's allow of it follows the fail mode. `protectionScopes/compute` is not used. +- Configured with `ENABLE_A365_PURVIEW_DLP`, `A365_PURVIEW_DLP_GRAPH_BASE_URL` (default + `https://graph.microsoft.com/v1.0`), `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE`, + `A365_PURVIEW_DLP_FAIL_MODE`, `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` (default 10000), + `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` and `A365_PURVIEW_DLP_RESPONSE_MODE` (`audit` or + `enforce`, default `audit`), or the matching `ToolingConfiguration` overrides; unknown values of + the enable flag and the modes are rejected. No new dependency. + +### Added (`@microsoft/agents-a365-tooling-extensions-agenthooks`, Purview) + +- **`A365PurviewInterceptor`** - An agent-hooks interceptor that evaluates the user's message at + `input` (`uploadText`, a block denies it with `purview:block`) and the reply at `output` + (`downloadText`: sent in the background and allowed at once in the default `audit` mode, awaited + in `enforce` mode), with a callback for each evaluation; `addA365Purview` registers it next to + `A365DefenderInterceptor` on one emitter. Contexts that cannot be verified follow the fail mode. + `createProtectionEmitter` accepts `purviewConfigProvider`, and its default interceptor timeout + covers the Purview timeout when Purview DLP is enabled; Defender-only emitters are unchanged. + ## [1.0.0] - 2026-04-30 ### Breaking Changes (`@microsoft/agents-a365-tooling`) diff --git a/CLAUDE.md b/CLAUDE.md index 7218068f..14b619ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,8 +83,8 @@ packages/ ├── agents-a365-observability-extensions-langchain/ # LangChain instrumentation ├── agents-a365-observability-extensions-openai/ # OpenAI instrumentation ├── agents-a365-notifications/ # Agent notification services -├── agents-a365-tooling/ # MCP server configuration, Defender RTP client -├── agents-a365-tooling-extensions-agenthooks/ # agent-hooks interceptor for Defender RTP +├── agents-a365-tooling/ # MCP server configuration, Defender RTP and Purview DLP clients +├── agents-a365-tooling-extensions-agenthooks/ # agent-hooks interceptors for Defender RTP and Purview DLP ├── agents-a365-tooling-extensions-claude/ # Claude/Anthropic integration ├── agents-a365-tooling-extensions-langchain/ # LangChain integration └── agents-a365-tooling-extensions-openai/ # OpenAI Agents SDK integration @@ -177,6 +177,7 @@ MCP tool server discovery and configuration: - **`Utility`**: Header composition, token validation, URL construction - **Interfaces**: `MCPServerConfig`, `McpClientTool`, `ToolOptions` - **`DefenderRtpClient`**: Microsoft Defender for AI real-time protection (opt-in, `ENABLE_A365_DEFENDER_RTP`). `evaluateHookContext` sends a copy of an agent-hooks/0.1 context, fitted to Defender's request validation, to the Defender prevention endpoint at `input`, `pre_tool_call`, `post_tool_call` and `output`, with the agent identity's app-only token (`DefenderRtpTokenResolvers.fromAgenticConnection`) and a unique `x-ms-correlation-id`, and returns the verdict; failures follow `A365_DEFENDER_RTP_FAIL_MODE`. No agent-hooks dependency: `A365DefenderInterceptor` in `agents-a365-tooling-extensions-agenthooks` drives it from an agent-hooks emitter. +- **`PurviewDlpClient`**: Microsoft Purview data loss prevention (opt-in, `ENABLE_A365_PURVIEW_DLP`). `evaluate` sends the text of a prompt (`uploadText`) or reply (`downloadText`) to Microsoft Graph `processContent` with the agentic user's delegated token (`PurviewDlpTokenResolvers.fromAgenticUser`, `/me`) or a host token (`fromAccessTokenProvider`) and a new `client-request-id`; a `restrictionAction: block` (or `action: blockAccess`) policy action blocks, and failures (including inline `processingErrors`) follow `A365_PURVIEW_DLP_FAIL_MODE`. `A365PurviewInterceptor` in `agents-a365-tooling-extensions-agenthooks` drives it at `input` and `output` (replies audited in the background by default, `A365_PURVIEW_DLP_RESPONSE_MODE`). ### Notifications (`@microsoft/agents-a365-notifications`) Extends `AgentApplication` with notification handlers via declaration merging: @@ -219,6 +220,13 @@ The keyword "Kairo" is legacy and should not appear in any code. Flag and remove | `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | Timeout of each Defender evaluation | Number (default: 10000, at most 2147481647) | | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | Override the Defender API token scope | Scope string | | `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | Max characters of each content string sent to Defender; the request carries at most four times as much content | Number (default: 20000) | +| `ENABLE_A365_PURVIEW_DLP` | Enable Purview data loss prevention (`PurviewDlpClient`) | `true`, `false` (default); also 1/0, yes/no, on/off; other values are rejected | +| `A365_PURVIEW_DLP_GRAPH_BASE_URL` | Microsoft Graph base URL of `processContent` | https URL (default: `https://graph.microsoft.com/v1.0`) | +| `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | Override the Microsoft Graph token scope | Scope string (default: `https://graph.microsoft.com/.default`) | +| `A365_PURVIEW_DLP_FAIL_MODE` | Behavior when no Purview verdict is obtained | `open` (default), `closed`; other values are rejected | +| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | Timeout of each Purview evaluation | Number (default: 10000, at most 2147481647) | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Max characters of text sent to Purview; longer text is sent truncated and follows the fail mode unless blocked | Number (default: 100000) | +| `A365_PURVIEW_DLP_RESPONSE_MODE` | How replies are handled | `audit` (default, never blocked), `enforce`; other values are rejected | | `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override observability auth scopes | Space-separated scope strings | | `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable Agent365 exporter | `true`, `false` (default) | | `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | Enable per-request export mode | `true`, `false` (default) | diff --git a/README.md b/README.md index 0a06acb4..6ac1b0d2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ For more detailed build instructions, see the [HOW_TO_BUILD.md](HOW_TO_BUILD.md) - **packages/agents-a365-observability-extensions-openai**: OpenAI observability extensions - **packages/agents-a365-runtime**: Microsoft Agent 365 Runtime - Core runtime utilities and extensions - **packages/agents-a365-tooling**: Microsoft Agent 365 Tooling SDK - Agent tooling and MCP integration -- **packages/agents-a365-tooling-extensions-agenthooks**: agent-hooks interceptor for Microsoft Defender for AI real-time protection +- **packages/agents-a365-tooling-extensions-agenthooks**: agent-hooks interceptors for Microsoft Defender for AI real-time protection and Microsoft Purview data loss prevention - **packages/agents-a365-tooling-extensions-claude**: Claude/Anthropic tooling extensions - **packages/agents-a365-tooling-extensions-langchain**: LangChain tooling extensions - **packages/agents-a365-tooling-extensions-openai**: OpenAI tooling extensions diff --git a/docs/design.md b/docs/design.md index 004dee1e..a97bbd44 100644 --- a/docs/design.md +++ b/docs/design.md @@ -186,7 +186,7 @@ Framework-specific instrumentations that integrate with the observability core: > **Detailed documentation**: [packages/agents-a365-tooling/docs/design.md](../packages/agents-a365-tooling/docs/design.md) -MCP (Model Context Protocol) tool server configuration and discovery, and the Microsoft Defender for AI real-time protection client. +MCP (Model Context Protocol) tool server configuration and discovery, the Microsoft Defender for AI real-time protection client, and the Microsoft Purview data loss prevention client. **Key Classes:** @@ -196,6 +196,8 @@ MCP (Model Context Protocol) tool server configuration and discovery, and the Mi | `Utility` | Header composition, token validation, URL construction | | `DefenderRtpClient` | Send agent-hooks contexts to the Defender prevention endpoint and return its verdict (opt-in, `ENABLE_A365_DEFENDER_RTP`) | | `DefenderRtpTokenResolvers` | The agent identity's app-only Defender token from an Agents SDK connection | +| `PurviewDlpClient` | Send prompt and reply text to Microsoft Purview DLP (Graph `processContent`) and return whether it may proceed (opt-in, `ENABLE_A365_PURVIEW_DLP`) | +| `PurviewDlpTokenResolvers` | The agentic user's delegated Microsoft Graph token from an Agents SDK connection, or a host's token | **Interfaces:** @@ -247,7 +249,7 @@ Framework-specific adapters for MCP tool integration, and the agent-hooks adapte | Package | Purpose | Design Doc | |---------|---------|------------| -| `tooling-extensions-agenthooks` | agent-hooks interceptor for Microsoft Defender for AI real-time protection | [design.md](../packages/agents-a365-tooling-extensions-agenthooks/docs/design.md) | +| `tooling-extensions-agenthooks` | agent-hooks interceptors for Microsoft Defender for AI real-time protection and Microsoft Purview data loss prevention | [design.md](../packages/agents-a365-tooling-extensions-agenthooks/docs/design.md) | | `tooling-extensions-claude` | Claude SDK integration | [design.md](../packages/agents-a365-tooling-extensions-claude/docs/design.md) | | `tooling-extensions-langchain` | LangChain integration | [design.md](../packages/agents-a365-tooling-extensions-langchain/docs/design.md) | | `tooling-extensions-openai` | OpenAI Agents SDK integration | [design.md](../packages/agents-a365-tooling-extensions-openai/docs/design.md) | diff --git a/packages/agents-a365-tooling-extensions-agenthooks/README.md b/packages/agents-a365-tooling-extensions-agenthooks/README.md index 0897cfcd..02e156aa 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/README.md +++ b/packages/agents-a365-tooling-extensions-agenthooks/README.md @@ -22,6 +22,12 @@ verdict decides: Other points (`agent_startup`, model calls, `agent_shutdown`) are allowed without a call. The host acts on the emission record: `proceeds(record)` is false when the action must not run. +`A365PurviewInterceptor` is an agent-hooks interceptor for Microsoft Purview data loss prevention (DLP): it sends the +text of the user's message and of the reply to Purview through Microsoft Graph, which applies the tenant's DLP +policies and records the interaction for audit (see +[Microsoft Purview data loss prevention](#microsoft-purview-data-loss-prevention)). Both interceptors register on one +emitter, and a deny from either blocks the action. + ## Installation ```bash @@ -35,7 +41,7 @@ must come from the same copy this package uses: with two copies, `addA365Defende `@responsibleai/agent-hooks` is a prerelease package with a native core for linux-x64 and linux-arm64 (glibc), darwin-x64, darwin-arm64 and win32-x64, and requires Node.js 20 or later. Only this package needs it: -`DefenderRtpClient` in `@microsoft/agents-a365-tooling` does not. +`DefenderRtpClient` and `PurviewDlpClient` in `@microsoft/agents-a365-tooling` do not. ## Authentication @@ -163,7 +169,8 @@ the last 1000 interception records in memory (drain them with `takeRecords()` or > deny, instead of following the fail mode. `createProtectionEmitter` sets the Defender timeout plus two seconds and > rejects an `interceptorTimeoutMilliseconds` that does not exceed the Defender timeout, or that is not an integer of > at most 2147483647 (Node's largest timer delay; a longer one fires after 1 ms); with your own emitter, pass a -> timeout above `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` as the third constructor argument. +> timeout above `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` as the third constructor argument. When Purview DLP is +> enabled, the same applies to the Purview timeout: the default is the longer of the two plus two seconds. ## Verdicts @@ -210,6 +217,195 @@ Defender logs each evaluation under it. A `400` reports the failed validation ru The same settings can be supplied per tenant or per request through a `ToolingConfiguration` with override functions, passed as `configProvider` to `DefenderRtpClient` and `createProtectionEmitter`. +## Microsoft Purview data loss prevention + +`A365PurviewInterceptor` evaluates the text of the user's message and of the reply with `PurviewDlpClient` from +`@microsoft/agents-a365-tooling`, which calls the Microsoft Graph `processContent` API +(`POST .../me/dataSecurityAndGovernance/processContent`). Purview applies the tenant's DLP policies for the agent's +application and records each interaction for audit (DSPM for AI, Activity explorer). + +| agent-hooks point | Purview activity | Behavior | +|---|---|---| +| `input` | `uploadText` (the user's message) | awaited; a block denies it, so the agent does not run | +| `output` | `downloadText` (the reply) | `audit` (default): sent without waiting and allowed at once; `enforce`: awaited and decided like the input | + +Other points are allowed without a call: Purview DLP evaluates prompts and replies, not tool calls. Content without +text is allowed without a call. A string is sent as it is; structured content (for example content parts) is sent as +its string and number values, in order, one per line, read only as far as `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` +(structured content that is still blank there follows the fail mode, as the rest was not read). + +### Response mode + +Purview DLP policies for custom AI apps restrict prompts (`UploadText`); restricting replies is not supported for +them. So by default (`A365_PURVIEW_DLP_RESPONSE_MODE=audit`) the reply is sent to Purview in the background and the +interceptor allows it at once: Purview still records it for audit and the evaluation reaches the evaluation callback, +but the reply is never delayed or blocked, whatever the fail mode. The background evaluation is bounded by the +Purview timeout, never cancelled by the emitter, and nothing it raises escapes (no unhandled rejection). With +`enforce`, the reply is awaited and decided like the input. + +### Authentication + +- **The agentic user (validated).** `PurviewDlpTokenResolvers.fromAgenticUser(connection)` asks the agent's Agents SDK + connection for the agentic user's delegated Microsoft Graph token + (`getAgenticUserToken(tenantId, agentId, agenticUserId, scopes)`, as the Agents SDK's own agentic authorization + does), so Purview evaluates the content as the agentic user (`/me`). The agent context needs `tenantId` + (`activity.getAgenticTenantId()`), `agentId` (`activity.getAgenticInstanceId()`) and `agenticUserId` + (`activity.getAgenticUser()`). The client caches the token per tenant, agent, agentic user and scope until five + minutes before it expires, and concurrent evaluations share one acquisition. +- **A token from the host.** `PurviewDlpTokenResolvers.fromAccessTokenProvider(getToken, userId?)` uses a Microsoft + Graph token the host supplies, for example an on-behalf-of token for the signed-in user (`/me`, delegated + `Content.Process.User`), or an app-only token with `Content.Process.All` and the user to evaluate for + (`/users/{userId}`); the app-only path has not been validated end to end. The client does not cache these tokens, + as they may be for a user the agent context does not identify. + +One deadline (`A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS`) covers the token acquisition and the request. The Graph base +URL must be an `https` URL, and the request does not follow a redirect, so the content and the token are never sent +anywhere else. + +### Prerequisites + +The tenant needs: + +1. **Licensing and billing.** Microsoft Purview licensing that covers DLP for AI apps (for example Microsoft 365 E5 or + E5 Compliance), and pay-as-you-go billing for Purview's AI features (an Azure subscription linked in the Purview + portal, **Settings > Billing**). +2. **DSPM for AI onboarded** in the Purview portal. Without the first two, `processContent` returns no policy actions + and nothing is ever blocked, with no error. +3. **A DLP policy for the agent.** A dedicated policy on the AI app location ("Managed cloud apps" in the portal, the + `Applications` workload), scoped to the agent blueprint's application id (what the SDK sends as the + `applicationLocation`), with a rule whose action restricts `UploadText` with **Block** (for example with Security & + Compliance PowerShell: `New-DlpCompliancePolicy -EnforcementPlanes Application` with an `Applications` location for + the app id, and `New-DlpComplianceRule -RestrictAccess @(@{setting='UploadText';value='Block'})` with the sensitive + information types to detect). The portal rejects a policy that mixes this location with Exchange, SharePoint, + OneDrive or Teams. A new policy can take up to an hour to apply. +4. **`Content.Process.User` for the agentic users.** Add the delegated Microsoft Graph permission + `Content.Process.User` to the blueprint's tenant-wide (`AllPrincipals`) delegated Microsoft Graph grant, appending + it to the scopes the grant already has, never replacing them: + `GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{blueprint-sp-object-id}' and consentType eq 'AllPrincipals' and resourceId eq '{graph-sp-object-id}'`, + then `PATCH https://graph.microsoft.com/v1.0/oauth2PermissionGrants/{grant-id}` with + `{"scope":" Content.Process.User"}`. The blueprint's inheritable permissions for Microsoft Graph + are of kind `allAllowed` (which `a365 setup` configures), so every agent identity's agentic user inherits the + scope. + +`protectionScopes/compute` is not used (it needs `ProtectionScopes.Compute.User`): every evaluation calls +`processContent`, and the returned `protectionScopeState` is only reported. + +### What Purview receives + +One conversation message (`processConversationMetadata`) per evaluation: the text (`textContent`), a name +(` `; Purview rejects an empty one), the conversation's `correlationId` and `sequenceNumber` +(the context's `session.id` and `sequence`), `isTruncated`, UTC timestamps, `contentCategory: ai` and the agent +(`agents[0]`: the agent identity id, the blueprint id, name and version); the activity; and the agent's name and +version as the integrated and protected application, with the blueprint's application id as the +`applicationLocation` DLP policies are scoped to. Every call sends a new GUID as `client-request-id` (also the content +entry's `identifier`), returned as `PurviewDlpEvaluationResult.correlationId`. Text longer than +`A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` (default 100000) is cut and sent with `isTruncated: true`. + +### Verdicts + +- A policy action with `restrictionAction: block` (or `action: blockAccess`) becomes a deny with reason `purview:block`, the message "The request + was blocked by a Microsoft Purview data loss prevention policy." (for a reply in `enforce` mode, "The response was + blocked ..."), and the `client-request-id` as evidence (`urn:a365:purview:`). Other actions (for + example `warn` or `audit`) allow the content and are counted in `decision.actionCount`. +- When no verdict is obtained (token, transport, timeout or HTTP failure, processing errors, which Graph reports with + HTTP 200, a response of another shape, or a call resolver that throws or resolves no agent identity), the verdict + follows `A365_PURVIEW_DLP_FAIL_MODE`: fail open (the default) allows with a `purview:unverified` warning that carries + the error; fail closed denies with reason `runtime_error:purview_unverified`, which is never reported as a detection. + The error names the failure or the exception type only (for example `http 403` or `token unavailable: TypeError`), + never a response body or token. +- **Text that does not fit** follows the fail mode too: Purview's block of the truncated text stands, but its allow + does not cover the rest, so it is treated like a missing verdict. + +`createProtectionEmitter` uses the `parallel/strictest` profile, so with both interceptors registered an action +proceeds only when Defender and Purview both allow it. agent-hooks runs them one after the other, each on its own +copy of the context, so at `input` their latencies add up. + +### Usage + +```typescript +import { + DefenderRtpClient, + DefenderRtpTokenResolvers, + PurviewDlpClient, + PurviewDlpTokenResolvers, +} from '@microsoft/agents-a365-tooling'; +import { + A365DefenderInterceptor, + A365PurviewInterceptor, + addA365Defender, + addA365Purview, + createProtectionEmitter, +} from '@microsoft/agents-a365-tooling-extensions-agenthooks'; +import { AgentContextBuilder, proceeds } from '@responsibleai/agent-hooks'; + +// Once per process: reads ENABLE_A365_DEFENDER_RTP, A365_DEFENDER_RTP_*, ENABLE_A365_PURVIEW_DLP and A365_PURVIEW_DLP_*. +const connection = adapter.connectionManager.getDefaultConnection(); +const defender = new DefenderRtpClient(); +const defenderTokens = DefenderRtpTokenResolvers.fromAgenticConnection(connection); +const purview = new PurviewDlpClient(); +const purviewTokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + +// In the turn handler: +const activity = context.activity; +const agentId = activity.getAgenticInstanceId(); // the agent identity +const tenantId = activity.getAgenticTenantId(); // the agent's tenant +const agenticUserId = activity.getAgenticUser(); // the agent's agentic user +const emitter = createProtectionEmitter(); +addA365Defender(emitter, new A365DefenderInterceptor( + defender, + () => agentId && tenantId ? { agent: { agentId, tenantId, requestId: activity.id }, tokenResolver: defenderTokens } : null, +)); +addA365Purview(emitter, new A365PurviewInterceptor( + purview, + // Returning null means there is no agentic user (for example a request that is not agentic): + // Purview is not called and the fail mode decides. + () => agentId && tenantId && agenticUserId + ? { + agent: { + agentId, + tenantId, + agenticUserId, + blueprintId: activity.recipient?.agenticAppBlueprintId, + agentName: 'SampleAgent', + }, + tokenResolver: purviewTokens, + } + : null, + (result) => console.log( + `Purview ${result.activity} allowed=${result.allowed} evaluated=${result.evaluated} ` + + `client-request-id=${result.correlationId}${result.error ? ` error=${result.error}` : ''}`), +)); + +const builder = new AgentContextBuilder({ + agentId: agentId ?? 'my-agent', + framework: 'my-framework', + sessionId: activity.conversation?.id ?? 'my-session', // Purview groups the conversation's messages under it +}); +const record = await emitter.emitUnchecked(builder.input(activity.text ?? '')); +if (!proceeds(record)) { + // Blocked: reply with record.verdict.message and stop the turn. +} +``` + +The interceptor sets the content entry's `correlationId` and `sequenceNumber` from the context's `session.id` and +`sequence`, so Purview's conversation lines up with the host's interception records. + +### Configuration + +| Variable | Meaning | +|---|---| +| `ENABLE_A365_PURVIEW_DLP` | `true` (or 1, yes, on) to call Purview; `false` (or 0, no, off) or unset leaves it off, and the interceptor allows everything without a call. Any other value fails at startup | +| `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` (the default) sends replies without waiting and never blocks them; `enforce` blocks them like prompts. Any other value is rejected | +| `A365_PURVIEW_DLP_FAIL_MODE` | `closed` blocks when no verdict is obtained; `open` (the default) allows. Any other value is rejected | +| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | deadline of each evaluation, token acquisition included (default 10000, at most 2147481647) | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | the longest text sent (default 100000); longer text is sent truncated and, unless Purview blocks it, follows the fail mode | +| `A365_PURVIEW_DLP_GRAPH_BASE_URL` | the Microsoft Graph base URL (default `https://graph.microsoft.com/v1.0`; `https` only) | +| `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | overrides the Microsoft Graph token scope (default `https://graph.microsoft.com/.default`) | + +As for Defender, the same settings can be supplied through a `ToolingConfiguration` with override functions, passed as +`configProvider` to `PurviewDlpClient` and `createProtectionEmitter` (or as `purviewConfigProvider` when the Purview +client uses its own configuration). + ## Support For issues, questions, or feedback: diff --git a/packages/agents-a365-tooling-extensions-agenthooks/docs/design.md b/packages/agents-a365-tooling-extensions-agenthooks/docs/design.md index bc23df35..95bb6511 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/docs/design.md +++ b/packages/agents-a365-tooling-extensions-agenthooks/docs/design.md @@ -10,12 +10,14 @@ The package connects Microsoft Agent 365 real-time protection to the host (an agent framework adapter, or the application itself) emits an `AgentContext` at each interception point of the agent loop; registered interceptors return verdicts, which the host composes and enforces. `A365DefenderInterceptor` forwards the contexts Microsoft Defender for AI evaluates to its prevention endpoint -and maps Defender's verdict back to an agent-hooks verdict. +and maps Defender's verdict back to an agent-hooks verdict. `A365PurviewInterceptor` sends the text of the user's +message and of the reply to Microsoft Purview data loss prevention (DLP) through Microsoft Graph and maps Purview's +policy actions back to an agent-hooks verdict. Both compose on one emitter. It is the only Agent 365 package that uses `@responsibleai/agent-hooks`, a prerelease package with a native core for a subset of platforms (no musl) and Node.js 20+, and declares it as a peer dependency (`>=0.1.0-alpha.5 <0.2.0`). -The Defender client itself (`DefenderRtpClient`) is in `@microsoft/agents-a365-tooling` and has no agent-hooks -dependency, so core tooling users do not take one on. +The Defender and Purview clients themselves (`DefenderRtpClient`, `PurviewDlpClient`) are in +`@microsoft/agents-a365-tooling` and have no agent-hooks dependency, so core tooling users do not take one on. ## Architecture @@ -27,23 +29,32 @@ dependency, so core tooling users do not take one on. └──────────────────────────────────────────────────────────────────────┘ │ intercept(context) ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ A365DefenderInterceptor │ -│ 1. Allow other points, and everything while Defender RTP is off │ -│ 2. resolveCall(context) ─► A365DefenderCall (identity + tokens) │ -│ 3. DefenderRtpClient.evaluateHookContext(...) │ -│ 4. toVerdict(result) ─► agent-hooks Verdict, returned │ -│ 5. onEvaluated(result) afterwards, for logging (errors ignored) │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ DefenderRtpClient (@microsoft/agents-a365-tooling) │ -│ fits the context to Defender's validation, agent identity token, │ -│ x-ms-correlation-id, POST .../v1/protection/evaluate, fail mode │ -└──────────────────────────────────────────────────────────────────────┘ +┌───────────────────────────────────┐ ┌────────────────────────────────────┐ +│ A365DefenderInterceptor │ │ A365PurviewInterceptor │ +│ 1. Allow other points, and all │ │ 1. Allow other points, all while │ +│ while Defender RTP is off │ │ Purview DLP is off, and no text │ +│ 2. resolveCall ─► identity+tokens │ │ 2. resolveCall ─► identity+tokens │ +│ 3. evaluateHookContext(...) │ │ 3. evaluate('uploadText' at input, │ +│ 4. toVerdict ─► Verdict │ │ 'downloadText' at output; audit │ +│ 5. onEvaluated afterwards │ │ mode: in the background) │ +│ │ │ 4. toVerdict ─► Verdict │ +│ │ │ 5. onEvaluated afterwards │ +└───────────────────────────────────┘ └────────────────────────────────────┘ + │ │ + ▼ ▼ +┌───────────────────────────────────┐ ┌────────────────────────────────────┐ +│ DefenderRtpClient (tooling) │ │ PurviewDlpClient (tooling) │ +│ fitted context, agent identity │ │ text, agentic user token, │ +│ token, x-ms-correlation-id, │ │ client-request-id, POST Graph │ +│ POST .../v1/protection/evaluate, │ │ .../me/dataSecurityAndGovernance/ │ +│ fail mode │ │ processContent, fail mode │ +└───────────────────────────────────┘ └────────────────────────────────────┘ ``` +The emitter composes the two verdicts (`parallel/strictest`: a deny from either wins). agent-hooks dispatches +parallel profiles one interceptor after the other over isolated copies of the context, so at `input` the two +latencies add up. + ## Key Components ### A365DefenderInterceptor ([A365DefenderInterceptor.ts](../src/A365DefenderInterceptor.ts)) @@ -93,24 +104,84 @@ truncated copy it blocks like a `deny`. it. The `runtime_error:` prefix is the agent-hooks convention for decision-runtime failures, so a fail-closed block is never reported as a detection. -### createProtectionEmitter and addA365Defender ([A365AgentHooks.ts](../src/A365AgentHooks.ts)) +### A365PurviewInterceptor ([A365PurviewInterceptor.ts](../src/A365PurviewInterceptor.ts)) + +An agent-hooks `Interceptor` registered under the name `purview`. + +```typescript +new A365PurviewInterceptor( + client: PurviewDlpClient, + resolveCall: (context: AgentContext) => A365PurviewCall | null | undefined | Promise<...>, + onEvaluated?: (result: PurviewDlpEvaluationResult) => void, +) +``` + +``` +input ──► text of input.content ──► PurviewDlpClient.evaluate('uploadText') ──► toVerdict, awaited +output ──► text of output.content ──► audit: evaluate('downloadText') in the background ──► allow at once + enforce: evaluate('downloadText') ──► toVerdict, awaited +other points, Purview DLP disabled, or content without text ──► allow, no call +``` + +- `input` is evaluated as `uploadText` and `output` as `downloadText`; other points, every point while + `ENABLE_A365_PURVIEW_DLP` is off, and content without text are allowed without calling `resolveCall` or Purview. + The text of a string is the string; of structured content, its string and number values in order, one per line, + each object read once and reading stopped once the text is longer than the limit (the client then marks it + truncated). Structured content that is still blank when reading stops is not empty, as the rest was never read: it + follows the fail mode without a call. +- `resolveCall` returns the agent identity and token resolver (`A365PurviewCall`). The interceptor sets the agent + context's `sessionId` and `sequence` from the context (`session.id`, `sequence`), so Purview's conversation lines up + with the interception records, and `agentName` from `agent.name` when the call sets none. `null` or `undefined` + means no agent identity: Purview is not called and the context follows the fail mode + (`no agent identity was resolved`). An exception from `resolveCall` does too, reporting only its type, as a host's + error may carry anything; an exception from `evaluate` (an invalid argument or configuration) keeps the SDK's + message. +- **Replies in `audit` mode** (`A365_PURVIEW_DLP_RESPONSE_MODE`, the default): Purview DLP policies for custom AI + apps cannot restrict replies, so the evaluation starts in the background and the interceptor returns `allow` at + once, whatever the fail mode. The background evaluation is bounded by the client's own timeout and never by the + emitter (which passes no cancellation), converts every failure to a not-evaluated result, reports to `onEvaluated`, + and catches whatever remains, so it never leaves an unhandled rejection. In `enforce` mode the reply is awaited and + mapped like the input. +- `onEvaluated` receives every evaluation, as for Defender: after the verdict is returned, off the timed path, with + its errors and rejections ignored. + +### toVerdict (Purview) + +| Purview result | agent-hooks verdict | +|---|---| +| evaluated, a policy action with `restrictionAction: block` or `action: blockAccess` | `deny`, reason `purview:block`, "The request was blocked by a Microsoft Purview data loss prevention policy." ("The response ..." for a reply), evidence `urn:a365:purview:` | +| evaluated, other or no actions | `allow` | +| not evaluated, fail open | `allow` with warning `purview:unverified` carrying the error | +| not evaluated, fail closed | `deny`, reason `runtime_error:purview_unverified`, same warning | +| allow of truncated text (`truncated`) | as not evaluated: the fail mode decides | +| block of truncated text | the block stands | + +Processing errors, which Graph reports with HTTP 200 (an empty entry name is a permanent bad request reported that +way), are not a verdict. An allow of truncated text does not cover the rest of it, so treating it as authoritative +would let padding carry sensitive text past the limit unseen. + +### createProtectionEmitter, addA365Defender and addA365Purview ([A365AgentHooks.ts](../src/A365AgentHooks.ts)) ```typescript -const emitter = addA365Defender(createProtectionEmitter(), interceptor); +const emitter = addA365Purview(addA365Defender(createProtectionEmitter(), defenderInterceptor), purviewInterceptor); ``` `createProtectionEmitter` returns an `InterceptionEmitter` in `enforce` mode with the `parallel/strictest` profile (`Composition.strictest('deny')`): an action proceeds only when every interceptor allows it, and a -transform conflict denies. Its per-interceptor timeout defaults to the Defender timeout plus two seconds, so the -client's own deadline and fail mode apply first; an explicit `interceptorTimeoutMilliseconds` that does not exceed -the Defender timeout, or that is not an integer of at most 2147483647 (Node's largest timer delay, beyond which a -timer fires after 1 ms), is rejected (`RangeError`). For the same reason the Defender timeout is at most -2147481647, so the default emitter timeout stays in range. An interceptor that exceeds the emitter timeout fails -closed as `host_error:interceptor_timeout`. This matters because the agent-hooks default interceptor timeout (5 s) -is below the Defender default (10 s): a host that registers the interceptor on its own emitter must set a longer -timeout. The timeout is read once, when the emitter is created; with a configuration whose Defender timeout changes -per request, create the emitter per turn or pass an `interceptorTimeoutMilliseconds` above the largest value. -The emitter keeps the last 1000 records (the agent-hooks default is unbounded). +transform conflict denies. Its per-interceptor timeout defaults to the longer client timeout plus two seconds: the +Defender timeout, and the Purview timeout when Purview DLP is enabled (read from `purviewConfigProvider`, which +defaults to `configProvider`). So each client's own deadline and fail mode apply first; an explicit +`interceptorTimeoutMilliseconds` that does not exceed that client timeout, or that is not an integer of at most +2147483647 (Node's largest timer delay, beyond which a timer fires after 1 ms), is rejected (`RangeError`). A +disabled Purview client makes no calls, so its timeout is then ignored, which keeps the emitter of a Defender-only +host unchanged. For the same reason as above, each client timeout is at most 2147481647, so the default emitter +timeout stays in range. An interceptor that exceeds the emitter timeout fails closed as +`host_error:interceptor_timeout`. This matters because the agent-hooks default interceptor timeout (5 s) is below +the clients' default (10 s): a host that registers the interceptors on its own emitter must set a longer timeout. +The timeout is read once, when the emitter is created; with a configuration whose timeouts change per request, +create the emitter per turn or pass an `interceptorTimeoutMilliseconds` above the largest value. The emitter keeps +the last 1000 records (the agent-hooks default is unbounded). `addA365Purview` registers the Purview interceptor +under the name `purview`, as `addA365Defender` does the Defender one under `defender`. ## Design Decisions @@ -121,9 +192,18 @@ The emitter keeps the last 1000 records (the agent-hooks default is unbounded). sequence and tool call ids are kept, so Defender's evaluations line up with the host's interception records. - **The fail mode, not a host error, decides a slow call.** One deadline covers the token acquisition and the request, and `createProtectionEmitter` rejects an interceptor timeout that does not exceed it. +- **Purview audits replies by default.** Purview DLP policies for custom AI apps restrict only prompts, so an awaited + reply evaluation would only add latency; the reply is sent in the background instead and still recorded for audit. + `enforce` is available for tenants whose policies can restrict replies. +- **Purview sees text, not the agent-hooks context.** `processContent` evaluates text content, so the interceptor + sends the text of the message or reply; the agent-hooks session id and sequence become the conversation's + `correlationId` and `sequenceNumber`. - **Mirrors the .NET SDK.** `A365DefenderInterceptor`, `A365DefenderCall`, `createProtectionEmitter` and `addA365Defender` correspond to the .NET `Microsoft.Agents.A365.Tooling.Extensions.AgentHooks` API, with the same - verdict mapping, defaults (10 s timeout, 20000 characters, fail open) and environment variables. + verdict mapping, defaults (10 s timeout, 20000 characters, fail open) and environment variables. The Purview + support (`A365PurviewInterceptor`, `A365PurviewCall`, `addA365Purview`, `PurviewDlpClient`) is designed to match the + .NET and Python SDKs, with the same environment variables, defaults (10 s timeout, 100000 characters, fail open, + audited replies) and verdict mapping. - **agent-hooks is a peer dependency, isolated to this package.** Only this package loads the native core. The application installs `@responsibleai/agent-hooks` itself, so the emitter, `AgentContextBuilder`, `proceeds` and `InterceptionBlocked` it imports are the same copy this package uses: with two copies, `addA365Defender` would @@ -137,12 +217,13 @@ The emitter keeps the last 1000 records (the agent-hooks default is unbounded). src/ ├── index.ts # Public API exports ├── A365DefenderInterceptor.ts # Interceptor, A365DefenderCall, toVerdict -└── A365AgentHooks.ts # createProtectionEmitter, addA365Defender +├── A365PurviewInterceptor.ts # Interceptor, A365PurviewCall, toVerdict +└── A365AgentHooks.ts # createProtectionEmitter, addA365Defender, addA365Purview ``` ## Dependencies -- `@microsoft/agents-a365-tooling` - `DefenderRtpClient`, Defender configuration +- `@microsoft/agents-a365-tooling` - `DefenderRtpClient`, `PurviewDlpClient`, their configuration - `@microsoft/agents-a365-runtime` - configuration provider types - `@responsibleai/agent-hooks` - `InterceptionEmitter`, `Interceptor`, `Verdict` (peer dependency, `>=0.1.0-alpha.5 <0.2.0`; development and tests pin `0.1.0-alpha.5`) diff --git a/packages/agents-a365-tooling-extensions-agenthooks/package.json b/packages/agents-a365-tooling-extensions-agenthooks/package.json index c20f1d98..eac22e6b 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/package.json +++ b/packages/agents-a365-tooling-extensions-agenthooks/package.json @@ -1,7 +1,7 @@ { "name": "@microsoft/agents-a365-tooling-extensions-agenthooks", "version": "0.0.0-placeholder", - "description": "Agent 365 Tooling SDK real-time protection on the agent-hooks control contract (Microsoft Defender for AI interceptor) for AI agents built with TypeScript/Node.js", + "description": "Agent 365 Tooling SDK real-time protection on the agent-hooks control contract (Microsoft Defender for AI and Microsoft Purview DLP interceptors) for AI agents built with TypeScript/Node.js", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", @@ -26,6 +26,8 @@ "typescript", "agent-hooks", "defender", + "purview", + "dlp", "security" ], "author": "Microsoft Corporation", diff --git a/packages/agents-a365-tooling-extensions-agenthooks/src/A365AgentHooks.ts b/packages/agents-a365-tooling-extensions-agenthooks/src/A365AgentHooks.ts index bd166652..9f9ea80c 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/src/A365AgentHooks.ts +++ b/packages/agents-a365-tooling-extensions-agenthooks/src/A365AgentHooks.ts @@ -5,8 +5,9 @@ import { Composition, EnforcementMode, InterceptionEmitter } from '@responsiblea import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; import { ToolingConfiguration, defaultToolingConfigurationProvider } from '@microsoft/agents-a365-tooling'; import { A365DefenderInterceptor } from './A365DefenderInterceptor'; +import { A365PurviewInterceptor } from './A365PurviewInterceptor'; -/** Extra time the emitter gives an interceptor beyond the Defender timeout. */ +/** Extra time the emitter gives an interceptor beyond the client timeout. */ const INTERCEPTOR_TIMEOUT_MARGIN_MILLISECONDS = 2000; /** Node's largest timer delay: a longer one fires after 1 ms. */ @@ -18,40 +19,52 @@ const MAX_RECORDS = 1000; /** Options for {@link createProtectionEmitter}. */ export interface A365ProtectionEmitterOptions { /** - * Per-interceptor timeout in milliseconds; it must exceed the Defender timeout. Defaults to the - * Defender timeout plus two seconds, so the client's own timeout and fail mode apply first. It is - * fixed when the emitter is created: if the Defender timeout can change per request, set it above - * the largest value, or create the emitter for each turn. + * Per-interceptor timeout in milliseconds; it must exceed the Defender timeout and, when Purview DLP is + * enabled, the Purview timeout. Defaults to the longer of the two plus two seconds, so each client's own + * timeout and fail mode apply first. It is fixed when the emitter is created: if a timeout can change per + * request, set it above the largest value, or create the emitter for each turn. */ interceptorTimeoutMilliseconds?: number; /** * The configuration whose Defender timeout sets the default; defaults to - * `defaultToolingConfigurationProvider`. Use the provider the `DefenderRtpClient` uses. + * `defaultToolingConfigurationProvider`. Use the provider the `DefenderRtpClient` uses. It also sets the + * Purview timeout, unless `purviewConfigProvider` is given. */ configProvider?: IConfigurationProvider; + /** + * The configuration whose Purview timeout, when Purview DLP is enabled in it, also sets the default; + * defaults to `configProvider`. Use the provider the `PurviewDlpClient` uses. + */ + purviewConfigProvider?: IConfigurationProvider; } /** * Creates an agent-hooks emitter for Agent 365 protection: `enforce` mode and the * `parallel/strictest` profile, so an action proceeds only when every interceptor allows it. + * Defender and Purview interceptors compose on one emitter, and a deny from either wins. * The emitter keeps the last 1000 interception records in memory; drain them with `takeRecords()` * or forward them with `setRecordSink()`. * - * The interceptor timeout is read once, when the emitter is created. If the configuration's Defender - * timeout can change per request (override functions), create the emitter for each turn from that - * turn's configuration, or pass an `interceptorTimeoutMilliseconds` above the largest Defender - * timeout; otherwise a slow call can end as a timeout deny instead of following the fail mode. + * The interceptor timeout is read once, when the emitter is created. If the configuration's Defender or + * Purview timeout can change per request (override functions), create the emitter for each turn from that + * turn's configuration, or pass an `interceptorTimeoutMilliseconds` above the largest client timeout; + * otherwise a slow call can end as a timeout deny instead of following the fail mode. * * @param options The interceptor timeout, or the configuration that sets it. - * @returns The emitter; register the Defender interceptor with {@link addA365Defender}. - * @throws When `interceptorTimeoutMilliseconds` does not exceed the Defender timeout (the client must - * apply its fail mode before the emitter times the interceptor out, which always denies), or is not an - * integer within Node's timer range (at most 2147483647 ms). + * @returns The emitter; register the interceptors with {@link addA365Defender} and {@link addA365Purview}. + * @throws When `interceptorTimeoutMilliseconds` does not exceed the Defender timeout or, when Purview DLP is + * enabled, the Purview timeout (the clients must apply their fail mode before the emitter times the + * interceptor out, which always denies), or is not an integer within Node's timer range (at most + * 2147483647 ms). */ export function createProtectionEmitter(options: A365ProtectionEmitterOptions = {}): InterceptionEmitter { - const defenderTimeout = (options.configProvider ?? defaultToolingConfigurationProvider) - .getConfiguration().defenderRtpTimeoutMilliseconds; - const timeout = options.interceptorTimeoutMilliseconds ?? defenderTimeout + INTERCEPTOR_TIMEOUT_MARGIN_MILLISECONDS; + const configProvider = options.configProvider ?? defaultToolingConfigurationProvider; + const defenderTimeout = configProvider.getConfiguration().defenderRtpTimeoutMilliseconds; + const purviewConfiguration = (options.purviewConfigProvider ?? configProvider).getConfiguration(); + // A disabled Purview client makes no calls, so its timeout does not bound the interceptor. + const purviewTimeout = purviewConfiguration.isPurviewDlpEnabled ? purviewConfiguration.purviewDlpTimeoutMilliseconds : 0; + const clientTimeout = Math.max(defenderTimeout, purviewTimeout); + const timeout = options.interceptorTimeoutMilliseconds ?? clientTimeout + INTERCEPTOR_TIMEOUT_MARGIN_MILLISECONDS; if (!Number.isInteger(timeout) || timeout > MAX_TIMER_MILLISECONDS) { throw new RangeError( `interceptorTimeoutMilliseconds (${timeout}) must be an integer of at most ${MAX_TIMER_MILLISECONDS} ms, ` @@ -59,9 +72,10 @@ export function createProtectionEmitter(options: A365ProtectionEmitterOptions = ); } - if (!(timeout > defenderTimeout)) { + if (!(timeout > clientTimeout)) { + const client = purviewTimeout > defenderTimeout ? 'Purview' : 'Defender'; throw new RangeError( - `interceptorTimeoutMilliseconds (${timeout}) must exceed the Defender timeout (${defenderTimeout} ms), ` + `interceptorTimeoutMilliseconds (${timeout}) must exceed the ${client} timeout (${clientTimeout} ms), ` + 'so the fail mode applies before the emitter times out.', ); } @@ -89,3 +103,22 @@ export function addA365Defender(emitter: InterceptionEmitter, interceptor: A365D return emitter.register(interceptor, A365DefenderInterceptor.NAME); } + +/** + * Registers the Purview interceptor under the name `purview`. + * + * @param emitter The emitter, for example from {@link createProtectionEmitter}. + * @param interceptor The Purview interceptor. + * @returns The emitter. + */ +export function addA365Purview(emitter: InterceptionEmitter, interceptor: A365PurviewInterceptor): InterceptionEmitter { + if (!emitter) { + throw new TypeError('emitter is required.'); + } + + if (!interceptor) { + throw new TypeError('interceptor is required.'); + } + + return emitter.register(interceptor, A365PurviewInterceptor.NAME); +} diff --git a/packages/agents-a365-tooling-extensions-agenthooks/src/A365PurviewInterceptor.ts b/packages/agents-a365-tooling-extensions-agenthooks/src/A365PurviewInterceptor.ts new file mode 100644 index 00000000..2ca5d928 --- /dev/null +++ b/packages/agents-a365-tooling-extensions-agenthooks/src/A365PurviewInterceptor.ts @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { AgentContext, Interceptor, Verdict, Warning } from '@responsibleai/agent-hooks'; +import { + PurviewDlpActivity, + PurviewDlpAgentContext, + PurviewDlpClient, + PurviewDlpEvaluationResult, + PurviewDlpTokenResolver, +} from '@microsoft/agents-a365-tooling'; + +const NO_IDENTITY_ERROR = 'no agent identity was resolved'; +const MAX_ERROR_CHARACTERS = 200; +const ERROR_TYPE = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; +const BLOCK_MESSAGES: Readonly> = { + uploadText: 'The request was blocked by a Microsoft Purview data loss prevention policy.', + downloadText: 'The response was blocked by a Microsoft Purview data loss prevention policy.', +}; + +/** The agent identity and credentials for the Purview call of one emitted context. */ +export interface A365PurviewCall { + /** + * The agent identity. The interceptor sets `sessionId` and `sequence` from the emitted context + * (`session.id`, `sequence`), and `agentName` from the context's `agent.name` when unset. + */ + agent: PurviewDlpAgentContext; + /** + * Resolves the Microsoft Graph token, for example `PurviewDlpTokenResolvers.fromAgenticUser(connection)`. + */ + tokenResolver: PurviewDlpTokenResolver; +} + +/** + * Returns the agent identity and token resolver for an emitted context, for example from the current turn. + * Returning null or undefined means no agent identity is available, so Purview cannot be called: the + * context follows the fail mode, like any other unverified context. + */ +export type A365PurviewCallResolver = ( + context: AgentContext, +) => A365PurviewCall | null | undefined | Promise; + +/** Receives each Purview evaluation, for logging and telemetry (for example the correlation id). */ +export type A365PurviewEvaluationListener = (result: PurviewDlpEvaluationResult) => void; + +/** + * An agent-hooks interceptor for Microsoft Purview data loss prevention (DLP). At `input`, the text of the + * user's message is evaluated as `uploadText`, and Purview's block denies it. At `output`, the reply's text + * is evaluated as `downloadText`: in the default `audit` response mode it is sent without waiting and + * allowed at once (Purview DLP restricts only prompts of custom AI apps, and records the reply for audit); + * in `enforce` mode it is awaited and decided like the input. Other points, and every point while Purview + * DLP is disabled, are allowed without a call; so is content without text. + * + * When no verdict is obtained (transport, authentication or processing failure, a call resolver that + * throws or resolves no agent identity), or Purview allowed only a truncated copy of text that did not fit + * `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS`, the verdict follows the configured fail mode: allow with a + * `purview:unverified` warning, or deny with reason `runtime_error:purview_unverified`, which is never + * reported as a detection. + */ +export class A365PurviewInterceptor implements Interceptor { + /** The name the interceptor is registered under. */ + public static readonly NAME = 'purview'; + + /** + * @param client The Purview client. + * @param resolveCall Returns the agent identity and token resolver for a context, for example from the + * current turn; null or undefined (no agent identity) follows the fail mode without a call. + * @param onEvaluated Receives each evaluation, including the ones without a verdict and the audited + * replies, for logging and telemetry (for example the correlation id). Errors it throws are ignored. + */ + constructor( + private readonly client: PurviewDlpClient, + private readonly resolveCall: A365PurviewCallResolver, + private readonly onEvaluated?: A365PurviewEvaluationListener, + ) { + if (!client) { + throw new TypeError('client is required.'); + } + + if (typeof resolveCall !== 'function') { + throw new TypeError('resolveCall is required.'); + } + } + + /** + * The Purview activity of an agent-hooks interception point. + * + * @param interceptionPoint The agent-hooks interception point. + * @returns `uploadText` for `input`, `downloadText` for `output`, otherwise undefined. + */ + public static activityOf(interceptionPoint: unknown): PurviewDlpActivity | undefined { + switch (interceptionPoint) { + case 'input': + return 'uploadText'; + case 'output': + return 'downloadText'; + default: + return undefined; + } + } + + /** @inheritdoc */ + public async intercept(context: AgentContext): Promise { + const activity = A365PurviewInterceptor.activityOf(context?.interception_point); + if (!activity) { + return { decision: 'allow' }; + } + + let result: PurviewDlpEvaluationResult | null; + try { + const configuration = this.client.configuration; + if (!configuration.isPurviewDlpEnabled) { + return { decision: 'allow' }; + } + + if (activity === 'downloadText' && configuration.purviewDlpResponseMode === 'audit') { + this.audit(context); + return { decision: 'allow' }; + } + + result = await this.evaluate(context, activity); + } catch (error) { + // An invalid configuration is never a verdict: it follows the fail mode. + result = this.client.unavailable(activity, describeError(error, true)); + } + + if (!result) { + return { decision: 'allow' }; + } + + const verdict = A365PurviewInterceptor.toVerdict(result); + this.notify(result); + return verdict; + } + + /** + * Maps a Purview evaluation to the agent-hooks verdict the host composes. + * + * @param result The Purview evaluation. + * @returns `allow` when Purview allowed the content; on a block, a `purview:block` deny with the + * `client-request-id` as evidence. A result without a verdict, or Purview's allow of truncated text, is + * unverified: an allow with a `purview:unverified` warning, or a `runtime_error:purview_unverified` deny. + */ + public static toVerdict(result: PurviewDlpEvaluationResult): Verdict { + if (!result) { + throw new TypeError('result is required.'); + } + + const name = A365PurviewInterceptor.NAME; + // A block stands even when the text was truncated; only an allow of all of it is authoritative. + const authoritative = result.evaluated && !result.truncated; + if (result.evaluated && (result.decision?.blockAction === true || (authoritative && !result.allowed))) { + return { + decision: 'deny', + reason: `${name}:block`, + message: result.blockReason ?? BLOCK_MESSAGES[result.activity] ?? BLOCK_MESSAGES.uploadText, + evidence: { + artefact: `${name}-verdict`, + verification_pointers: { correlation: `urn:a365:${name}:${encodeURIComponent(result.correlationId)}` }, + }, + }; + } + + if (authoritative) { + return { decision: 'allow' }; + } + + const warnings: Warning[] = [{ reason: `${name}:unverified`, message: result.error ?? 'no verdict was returned' }]; + return result.allowed + ? { decision: 'allow', warnings } + : { + decision: 'deny', + reason: `runtime_error:${name}_unverified`, + // The client sets the reason when it fails closed. + ...(result.blockReason ? { message: result.blockReason } : {}), + warnings, + }; + } + + /** + * Evaluates the context's text. Never rejects for a missing identity or an invalid context: those become + * not-evaluated results, which follow the fail mode. + */ + private async evaluate(context: AgentContext, activity: PurviewDlpActivity): Promise { + let limit: number; + try { + limit = this.client.configuration.purviewDlpMaxContentCharacters; + } catch (error) { + return this.client.unavailable(activity, describeError(error, true)); + } + + let text: string; + let complete: boolean; + try { + ({ text, complete } = contentText(contentOf(context, activity), limit)); + } catch (error) { + // The content is the host's, so only the type of what it raised is reported. + return this.client.unavailable(activity, `content could not be read: ${describeError(error, false)}`); + } + + if (!text.trim()) { + // Nothing to evaluate, so no identity is needed. Content whose first characters are blank but that goes + // on past the limit is not empty: the rest was never read, so it follows the fail mode. + return complete + ? null + : this.client.unavailable( + activity, + `content exceeded A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (${limit}) before any text; Purview was not called`, + ); + } + + // Read before anything is awaited: a reply is evaluated after the interceptor has returned. + const turn = turnOf(context); + let call: A365PurviewCall | null | undefined; + try { + call = await this.resolveCall(context); + } catch (error) { + // The host's error may carry anything, so only its type is reported. + return this.client.unavailable(activity, `${NO_IDENTITY_ERROR}: ${describeError(error, false)}`); + } + + // Without an agent identity Purview cannot be called: the content is unverified, not allowed. + if (!call) { + return this.client.unavailable(activity, NO_IDENTITY_ERROR); + } + + try { + return await this.client.evaluate(activity, text, agentFor(call.agent, turn), call.tokenResolver); + } catch (error) { + // The client throws only for invalid arguments or configuration, with the SDK's own messages. + return this.client.unavailable(activity, describeError(error, true)); + } + } + + /** + * Sends the reply to Purview without waiting. The evaluation is bounded by the client's own timeout, + * never by the emitter's cancellation, and reports to the listener; nothing it raises escapes. + */ + private audit(context: AgentContext): void { + this.evaluate(context, 'downloadText').then( + (result) => { + if (result) { + this.notify(result); + } + }, + () => undefined, + ); + } + + /** + * Hands the evaluation to the listener after the verdict is returned, off the interceptor's timed path, + * so a slow listener cannot delay the action. Its errors and rejections are ignored, so logging cannot + * change a verdict. + */ + private notify(result: PurviewDlpEvaluationResult): void { + const listener = this.onEvaluated; + if (!listener) { + return; + } + + setImmediate(() => { + try { + const pending: unknown = listener(result); + // Any thenable, including a promise from another realm, which `instanceof Promise` misses. + if (isThenable(pending)) { + Promise.resolve(pending).catch(() => undefined); + } + } catch (_error) { + // Ignored by design. + } + }); + } +} + +/** What the context says about the conversation and the agent. */ +interface Turn { + sessionId?: string; + sequence?: number; + agentName?: string; +} + +function turnOf(context: AgentContext): Turn { + const session = context['session']; + const sessionId = isObject(session) && typeof session['id'] === 'string' && session['id'] ? session['id'] : undefined; + const sequence = typeof context['sequence'] === 'number' ? context['sequence'] : undefined; + const agent = context['agent']; + const agentName = isObject(agent) && typeof agent['name'] === 'string' ? agent['name'] : undefined; + return { sessionId, sequence, agentName }; +} + +/** + * The agent identity for one context: its session id and sequence (which line Purview's conversation up with + * the host's interception records) from the context, and the context's agent name when none is set. + */ +function agentFor(agent: PurviewDlpAgentContext, turn: Turn): PurviewDlpAgentContext { + if (!isObject(agent)) { + // The client rejects it, and the context follows the fail mode. + return agent; + } + + const sessionId = turn.sessionId ?? agent.sessionId; + const sequence = turn.sequence ?? agent.sequence; + const agentName = typeof agent.agentName === 'string' && agent.agentName.trim() ? agent.agentName : turn.agentName; + return { + ...agent, + ...(sessionId !== undefined ? { sessionId } : {}), + ...(sequence !== undefined ? { sequence } : {}), + ...(agentName ? { agentName } : {}), + }; +} + +function contentOf(context: AgentContext, activity: PurviewDlpActivity): unknown { + const holder = context[activity === 'uploadText' ? 'input' : 'output']; + return isObject(holder) ? holder['content'] : undefined; +} + +/** + * The text of a message's content: a string as it is; structured content (for example content parts) as + * its string and number values in order, one per line, each object read once. Reading stops once the text + * is longer than `limit`, as the client sends at most that much and marks the content as truncated; + * `complete` tells whether everything was read. + */ +function contentText(content: unknown, limit: number): { text: string; complete: boolean } { + if (typeof content === 'string') { + return { text: content, complete: true }; + } + + const parts: string[] = []; + // The length of the joined text, separators included. + let length = 0; + const pending: unknown[] = [content]; + const seen = new Set(); + while (pending.length > 0 && length <= limit) { + const node = pending.pop(); + if (typeof node === 'object' && node !== null) { + if (!seen.has(node)) { + seen.add(node); + const children: unknown[] = Array.isArray(node) ? node : Object.values(node); + for (let index = children.length - 1; index >= 0; index -= 1) { + pending.push(children[index]); + } + } + + continue; + } + + const text = typeof node === 'string' + ? node + : (typeof node === 'number' && Number.isFinite(node)) || typeof node === 'bigint' ? String(node) : ''; + if (text) { + length += (parts.length > 0 ? 1 : 0) + text.length; + parts.push(text); + } + } + + return { text: parts.join('\n'), complete: pending.length === 0 }; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isThenable(value: unknown): value is PromiseLike { + return (typeof value === 'object' || typeof value === 'function') + && value !== null + && typeof (value as { then?: unknown }).then === 'function'; +} + +/** + * The exception type, for example `TypeError`, and with `withMessage` its message: only for the SDK's own + * errors, as a host's may carry a token or a response body. + */ +function describeError(error: unknown, withMessage: boolean): string { + if (typeof error !== 'object' || error === null) { + return 'unknown error'; + } + + const name = (error as { name?: unknown }).name; + const type = typeof name === 'string' && ERROR_TYPE.test(name) ? name : 'Error'; + if (!withMessage) { + return type; + } + + const message = (error as { message?: unknown }).message; + const description = `${type}: ${typeof message === 'string' ? message : ''}`.replace(/\s+/g, ' ').trim(); + return description.length > MAX_ERROR_CHARACTERS ? `${description.slice(0, MAX_ERROR_CHARACTERS)}...` : description; +} diff --git a/packages/agents-a365-tooling-extensions-agenthooks/src/index.ts b/packages/agents-a365-tooling-extensions-agenthooks/src/index.ts index 0764016a..4be6c7b0 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/src/index.ts +++ b/packages/agents-a365-tooling-extensions-agenthooks/src/index.ts @@ -2,4 +2,5 @@ // Licensed under the MIT License. export * from './A365DefenderInterceptor'; +export * from './A365PurviewInterceptor'; export * from './A365AgentHooks'; diff --git a/packages/agents-a365-tooling/README.md b/packages/agents-a365-tooling/README.md index 14cf8760..27aa272a 100644 --- a/packages/agents-a365-tooling/README.md +++ b/packages/agents-a365-tooling/README.md @@ -19,6 +19,10 @@ For detailed usage examples and implementation guidance, see the [Microsoft Agen `DefenderRtpClient` sends agent-hooks contexts to the Microsoft Defender for AI prevention endpoint at the points Defender evaluates (`input`, `pre_tool_call`, `post_tool_call`, `output`) and returns its verdict, using the agent identity's own app-only token. It is disabled by default (`ENABLE_A365_DEFENDER_RTP`). To use it from an agent-hooks host, register `A365DefenderInterceptor` from [`@microsoft/agents-a365-tooling-extensions-agenthooks`](../agents-a365-tooling-extensions-agenthooks/README.md), which also lists the configuration. See the [design document](docs/design.md) for details. +## Microsoft Purview data loss prevention + +`PurviewDlpClient` sends the text of a prompt (`uploadText`) or a reply (`downloadText`) to Microsoft Purview through the Microsoft Graph `processContent` API, which applies the tenant's data loss prevention (DLP) policies for the agent and records the interaction for audit, and returns whether the content may proceed. It authenticates as the agent's agentic user (`PurviewDlpTokenResolvers.fromAgenticUser`) or with a Microsoft Graph token from the host (`PurviewDlpTokenResolvers.fromAccessTokenProvider`). It is disabled by default (`ENABLE_A365_PURVIEW_DLP`). To use it from an agent-hooks host, register `A365PurviewInterceptor` from [`@microsoft/agents-a365-tooling-extensions-agenthooks`](../agents-a365-tooling-extensions-agenthooks/README.md), which also lists the configuration and the tenant prerequisites. See the [design document](docs/design.md) for details. + ## Support For issues, questions, or feedback: diff --git a/packages/agents-a365-tooling/docs/design.md b/packages/agents-a365-tooling/docs/design.md index b8e46712..6cb62ca8 100644 --- a/packages/agents-a365-tooling/docs/design.md +++ b/packages/agents-a365-tooling/docs/design.md @@ -6,7 +6,7 @@ This document describes the architecture and design of the `@microsoft/agents-a3 The tooling package provides MCP (Model Context Protocol) tool server configuration and discovery services. It enables agents to dynamically discover and connect to tool servers for extending agent capabilities. -It also provides `DefenderRtpClient`, the client for Microsoft Defender for AI real-time protection (Defender RTP) on the agent-hooks control contract. The agent-hooks interceptor that drives it is in `@microsoft/agents-a365-tooling-extensions-agenthooks`. +It also provides `DefenderRtpClient`, the client for Microsoft Defender for AI real-time protection (Defender RTP) on the agent-hooks control contract, and `PurviewDlpClient`, the client for Microsoft Purview data loss prevention (DLP) through Microsoft Graph. The agent-hooks interceptors that drive them are in `@microsoft/agents-a365-tooling-extensions-agenthooks`. ## Architecture @@ -243,6 +243,70 @@ if (result && !result.allowed) { /* block: result.blockReason */ } The client has no agent-hooks dependency: contexts are plain JSON (`DefenderRtpHookContext`). +### PurviewDlpClient ([PurviewDlpClient.ts](../src/purview/PurviewDlpClient.ts)) + +Client for Microsoft Purview data loss prevention (DLP) through the Microsoft Graph `processContent` API +(`POST {graph}/me/dataSecurityAndGovernance/processContent`, or `/users/{userId}/...`). Purview applies the tenant's +DLP policies for the agent's application and records each interaction for audit. + +```typescript +import { PurviewDlpClient, PurviewDlpTokenResolvers } from '@microsoft/agents-a365-tooling'; + +const purview = new PurviewDlpClient(); // defaultToolingConfigurationProvider +const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + +const result = await purview.evaluate('uploadText', userMessage, { + agentId, tenantId, agenticUserId, blueprintId, agentName, sessionId: conversationId, +}, tokens); +// null when disabled or the text is empty +if (result && !result.allowed) { /* block: result.blockReason */ } +``` + +- **Activities**: `uploadText` for a prompt (content the user sends the agent), `downloadText` for a reply. The + client is not tied to agent-hooks; `A365PurviewInterceptor` maps `input` to `uploadText` and `output` to + `downloadText`. +- **Request**: one `processConversationMetadata` content entry with a `textContent` and a non-empty `name` + (` `; Graph reports an empty name as a permanent bad request inline, with HTTP 200), the + conversation's `correlationId` (the agent context's `sessionId`) and `sequenceNumber` (its `sequence`, or else a + per-session counter over the last 1000 sessions), UTC timestamps, `contentCategory: ai` and + `agents[0]` (`identifier` the agent identity, `blueprintId` when known, `name`, `version`). + `integratedAppMetadata` and `protectedAppMetadata` carry the agent's name and version, and + `protectedAppMetadata.applicationLocation` the application DLP policies are scoped to: `applicationId`, else the + blueprint id, else the agent id. Every string is made well formed (a lone surrogate becomes U+FFFD). +- **Correlation**: every call sends a new GUID as `client-request-id`, which is also the content entry's + `identifier`; it is returned as `result.correlationId`. +- **Verdict**: any `policyActions[]` entry whose `restrictionAction` is `block` or whose `action` is `blockAccess` + (any case; the rule of Microsoft's own Purview integrations) blocks the content, even beside processing errors or + malformed actions. Other actions (for example `warn` or `audit`) allow it and are counted in + `decision.actionCount`. `202` and `204` are an evaluated allow. Every node read is shape-checked. +- **Failures**: non-empty `processingErrors` (no block), a response without a well-formed `policyActions` list, a + non-2xx status, a timeout, a transport error or an unparsable body returns `evaluated: false`, with `allowed` + following `purviewDlpFailClosed`. `error` names the failure, or the exception type only (`http 403`, + `request timeout`, `token unavailable: TypeError`), never a response body or token; the SDK's own argument errors + keep their message. An invalid argument (for example no `sessionId`) throws; `unavailable(...)` builds the + matching not-evaluated result. +- **Truncation**: text longer than `purviewDlpMaxContentCharacters` (default 100000) is cut (never splitting a + surrogate pair) and sent with `isTruncated: true`. A block still stands, but an allow does not cover the rest: + the result is `truncated: true` and `allowed` follows the fail mode, so text padded past the limit cannot be + authorized unseen. +- **Authentication**: a `PurviewDlpTokenResolver` is `(agent, scopes, signal) => { accessToken, userId? }`; without + `userId` the client calls `/me`, with it `/users/{userId}`. + `PurviewDlpTokenResolvers.fromAgenticUser(connection)` returns the agentic user's delegated Microsoft Graph token + from the Agents SDK connection (`getAgenticUserToken(tenantId, agentId, agenticUserId, scopes)`), for `/me`; the + client caches it per tenant, agent, agentic user and scope until five minutes before it expires, concurrent + evaluations share one acquisition, the shared entry is dropped when it completes, a failed acquisition is never + cached, and within the last five minutes evaluations keep the still-valid token while it is refreshed. + `PurviewDlpTokenResolvers.fromAccessTokenProvider(getToken, userId?)` uses a token from the host (for example an + on-behalf-of token for the signed-in user, or an app-only token with `Content.Process.All` and a `userId`, which + has not been validated end to end); the client never caches a host's token, as it may be for a user the agent + context does not identify. One deadline (`purviewDlpTimeoutMilliseconds`) covers the token acquisition and the + request. The Graph base URL must be an absolute `https` URL without credentials, query or fragment, and the + request does not follow a redirect (`redirect: 'error'`). +- **Not used**: `protectionScopes/compute` (it needs `ProtectionScopes.Compute.User`); every evaluation calls + `processContent`, and `protectionScopeState` is only reported. + +The client has no agent-hooks dependency. + ## Data Models ### MCPServerConfig ([contracts.ts](../src/contracts.ts)) @@ -355,6 +419,13 @@ const customConfig = new ToolingConfiguration({ | `defenderRtpTimeoutMilliseconds` | `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | `10000` | Timeout of each evaluation; at most 2147481647, as Node fires a longer timer after 1 ms | | `defenderRtpAuthenticationScope` | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | Defender API scope | OAuth scope of the Defender token | | `defenderRtpMaxContentCharacters` | `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | `20000` | Maximum characters of each content string; the request carries at most four times as much content | +| `isPurviewDlpEnabled` | `ENABLE_A365_PURVIEW_DLP` | `false` | Enables Purview DLP (`PurviewDlpClient`); accepts true/false, 1/0, yes/no or on/off, and any other value throws | +| `purviewDlpGraphBaseUrl` | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | `https://graph.microsoft.com/v1.0` | Microsoft Graph base URL of `processContent`; the client requires an absolute `https` URL | +| `purviewDlpAuthenticationScope` | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | `https://graph.microsoft.com/.default` | OAuth scope of the Microsoft Graph token | +| `purviewDlpFailClosed` | `A365_PURVIEW_DLP_FAIL_MODE` | `false` (open) | `closed` blocks when no verdict is obtained; values other than `open` and `closed` throw | +| `purviewDlpTimeoutMilliseconds` | `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | `10000` | Timeout of each evaluation, token acquisition included; at most 2147481647 | +| `purviewDlpMaxContentCharacters` | `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | `100000` | Maximum characters of text sent; longer text is sent truncated and, unless Purview blocks it, follows the fail mode | +| `purviewDlpResponseMode` | `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` | `audit` or `enforce` handling of replies; other values throw | | `clusterCategory` | `CLUSTER_CATEGORY` | `prod` | (Inherited) Environment cluster | | `isDevelopmentEnvironment` | - | Derived | (Inherited) true if cluster is 'local' or 'dev' | | `isNodeEnvDevelopment` | `NODE_ENV` | `false` | (Inherited) true if NODE_ENV='development' | @@ -372,11 +443,17 @@ src/ │ ├── index.ts # Configuration exports │ ├── ToolingConfigurationOptions.ts # Options type │ └── ToolingConfiguration.ts # Configuration class -└── defender/ - ├── index.ts # Defender RTP exports +├── defender/ +│ ├── index.ts # Defender RTP exports +│ ├── contracts.ts # Agent context, token resolver, evaluation result +│ ├── DefenderRtpClient.ts # Defender prevention endpoint client +│ └── DefenderRtpTokenResolvers.ts # Agent identity token resolver (fromAgenticConnection) +└── purview/ + ├── index.ts # Purview DLP exports ├── contracts.ts # Agent context, token resolver, evaluation result - ├── DefenderRtpClient.ts # Defender prevention endpoint client - └── DefenderRtpTokenResolvers.ts # Agent identity token resolver (fromAgenticConnection) + ├── PurviewDlpClient.ts # Microsoft Graph processContent client + ├── PurviewDlpTokenResolvers.ts # fromAgenticUser, fromAccessTokenProvider + └── internal.ts # Shared helpers (not exported) ``` ## Environment Variables @@ -392,6 +469,13 @@ src/ | `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | Timeout of each evaluation (at most 2147481647) | `10000` | | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | OAuth scope of the Defender token | Defender API scope | | `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | Maximum characters of each content string; the request carries at most four times as much content | `20000` | +| `ENABLE_A365_PURVIEW_DLP` | Enables Purview DLP (true/false, 1/0, yes/no or on/off; other values are rejected) | `false` | +| `A365_PURVIEW_DLP_GRAPH_BASE_URL` | Microsoft Graph base URL of `processContent` (absolute `https`) | `https://graph.microsoft.com/v1.0` | +| `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | OAuth scope of the Microsoft Graph token | `https://graph.microsoft.com/.default` | +| `A365_PURVIEW_DLP_FAIL_MODE` | `closed` blocks when no verdict is obtained; values other than `open` and `closed` are rejected | `open` | +| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | Timeout of each evaluation (at most 2147481647) | `10000` | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Maximum characters of text sent to Purview; longer text is sent truncated and, unless Purview blocks it, follows the fail mode | `100000` | +| `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` sends replies without waiting and never blocks them; `enforce` blocks them like prompts; other values are rejected | `audit` | ## Error Handling @@ -422,7 +506,7 @@ The tooling package is extended by framework-specific packages: | Extension Package | Purpose | |-------------------|---------| -| `tooling-extensions-agenthooks` | agent-hooks interceptor for Defender RTP (`A365DefenderInterceptor`) | +| `tooling-extensions-agenthooks` | agent-hooks interceptors for Defender RTP (`A365DefenderInterceptor`) and Purview DLP (`A365PurviewInterceptor`) | | `tooling-extensions-claude` | Claude SDK integration | | `tooling-extensions-langchain` | LangChain integration | | `tooling-extensions-openai` | OpenAI Agents SDK integration | diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts index ca62d04e..2727a63c 100644 --- a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts @@ -4,17 +4,23 @@ import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; import { ToolingConfigurationOptions } from './ToolingConfigurationOptions'; import { MCPServerConfig } from '../contracts'; +import type { PurviewDlpResponseMode } from '../purview/contracts'; // Constants for tooling-specific settings const MCP_PLATFORM_PROD_BASE_URL = 'https://agent365.svc.cloud.microsoft'; const PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE = 'ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'; const DEFAULT_DEFENDER_RTP_TIMEOUT_MILLISECONDS = 10000; const DEFAULT_DEFENDER_RTP_MAX_CONTENT_CHARACTERS = 20000; +const DEFAULT_PURVIEW_DLP_TIMEOUT_MILLISECONDS = 10000; +const DEFAULT_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = 100000; /** * The longest Defender timeout: Node's largest timer delay (a longer one fires after 1 ms), less the two * seconds the protection emitter adds. */ const MAX_DEFENDER_RTP_TIMEOUT_MILLISECONDS = 2_147_483_647 - 2_000; +/** The longest Purview timeout, for the same reason as the Defender one. */ +const MAX_PURVIEW_DLP_TIMEOUT_MILLISECONDS = 2_147_483_647 - 2_000; +const PURVIEW_DLP_RESPONSE_MODES: ReadonlySet = new Set(['audit', 'enforce']); /** * The largest Defender content limit, as in the .NET (int32) and Python SDKs: the client's budgets are @@ -28,6 +34,12 @@ export const DEFENDER_RTP_API_APP_ID = '86a21212-634e-4553-b3d6-e477e4c9d9ec'; /** Default Defender RTP token scope: the Defender API. */ export const DEFAULT_DEFENDER_RTP_AUTHENTICATION_SCOPE = `api://${DEFENDER_RTP_API_APP_ID}/.default`; +/** Default Microsoft Graph base URL of the Purview `processContent` call. */ +export const DEFAULT_PURVIEW_DLP_GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; + +/** Default Purview DLP token scope: Microsoft Graph (`Content.Process.User` for a delegated token). */ +export const DEFAULT_PURVIEW_DLP_AUTHENTICATION_SCOPE = 'https://graph.microsoft.com/.default'; + /** * Resolve the OAuth scope to request for a given MCP server. * @@ -259,6 +271,132 @@ export class ToolingConfiguration extends RuntimeConfiguration { return maximum; } + /** + * Whether Microsoft Purview data loss prevention (DLP) is enabled. When false, `PurviewDlpClient` + * evaluates nothing and makes no calls. `ENABLE_A365_PURVIEW_DLP` accepts true/false, 1/0, yes/no or + * on/off (any case; blank means false); any other value throws. + */ + get isPurviewDlpEnabled(): boolean { + const override = this.toolingOverrides.isPurviewDlpEnabled?.(); + if (override !== undefined) return override; + + // Strict, so a typo fails at startup instead of silently turning protection off. + const value = process.env.ENABLE_A365_PURVIEW_DLP?.trim().toLowerCase(); + if (!value || ['false', '0', 'no', 'off'].includes(value)) { + return false; + } + + if (['true', '1', 'yes', 'on'].includes(value)) { + return true; + } + + throw new Error('ENABLE_A365_PURVIEW_DLP must be true or false (or 1/0, yes/no, on/off).'); + } + + /** + * Microsoft Graph base URL of the Purview `processContent` call, an absolute https URL (default + * `https://graph.microsoft.com/v1.0`). `PurviewDlpClient` rejects any other scheme. + */ + get purviewDlpGraphBaseUrl(): string { + const override = this.toolingOverrides.purviewDlpGraphBaseUrl?.(); + if (override?.trim()) return normalizeUrl(override); + + const envValue = process.env.A365_PURVIEW_DLP_GRAPH_BASE_URL?.trim(); + if (envValue) return normalizeUrl(envValue); + + return DEFAULT_PURVIEW_DLP_GRAPH_BASE_URL; + } + + /** + * OAuth scope of the Purview DLP token (default `https://graph.microsoft.com/.default`). A delegated + * token must carry `Content.Process.User`. + */ + get purviewDlpAuthenticationScope(): string { + const override = this.toolingOverrides.purviewDlpAuthenticationScope?.()?.trim(); + if (override) return override; + + const envValue = process.env.A365_PURVIEW_DLP_AUTHENTICATION_SCOPE?.trim(); + if (envValue) return envValue; + + return DEFAULT_PURVIEW_DLP_AUTHENTICATION_SCOPE; + } + + /** + * Deadline in milliseconds of each Purview evaluation, token acquisition included (default 10000). + * At most 2147481647: Node fires a longer timer after 1 ms, which would fail every evaluation. + */ + get purviewDlpTimeoutMilliseconds(): number { + const override = this.toolingOverrides.purviewDlpTimeoutMilliseconds?.(); + const timeout = override + ?? RuntimeConfiguration.parseEnvInt( + process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS, + DEFAULT_PURVIEW_DLP_TIMEOUT_MILLISECONDS, + ); + + if (!Number.isInteger(timeout) || timeout <= 0 || timeout > MAX_PURVIEW_DLP_TIMEOUT_MILLISECONDS) { + throw new Error(`purviewDlpTimeoutMilliseconds must be a positive integer of at most ${MAX_PURVIEW_DLP_TIMEOUT_MILLISECONDS}.`); + } + return timeout; + } + + /** + * Whether an evaluation that returns no verdict (timeout, transport, authentication, HTTP or + * processing error) blocks the content (`A365_PURVIEW_DLP_FAIL_MODE=closed`). Defaults to false: + * fail open. `A365_PURVIEW_DLP_FAIL_MODE` accepts `open` or `closed` (any case); any other value + * throws, so a typo cannot silently turn fail-closed into fail-open. + */ + get purviewDlpFailClosed(): boolean { + const override = this.toolingOverrides.purviewDlpFailClosed?.(); + if (override !== undefined) return override; + + const mode = process.env.A365_PURVIEW_DLP_FAIL_MODE?.trim().toLowerCase(); + if (!mode || mode === 'open') { + return false; + } + + if (mode === 'closed') { + return true; + } + + throw new Error("A365_PURVIEW_DLP_FAIL_MODE must be 'open' or 'closed'."); + } + + /** + * Maximum characters of the text sent to Purview (default 100000). Longer text is cut and sent with + * `isTruncated: true`; Purview's block of it stands, but its allow does not cover the rest, so the + * content then follows the fail mode. + */ + get purviewDlpMaxContentCharacters(): number { + const override = this.toolingOverrides.purviewDlpMaxContentCharacters?.(); + const maximum = override + ?? RuntimeConfiguration.parseEnvInt( + process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS, + DEFAULT_PURVIEW_DLP_MAX_CONTENT_CHARACTERS, + ); + + if (!Number.isInteger(maximum) || maximum <= 0) { + throw new Error('purviewDlpMaxContentCharacters must be a positive integer.'); + } + return maximum; + } + + /** + * How the agent's reply (`downloadText`) is handled: `audit` (default) sends it to Purview without + * waiting and never blocks it, as Purview DLP restricts only prompts (`uploadText`) of custom AI apps; + * `enforce` waits for Purview and blocks the reply like a prompt. `A365_PURVIEW_DLP_RESPONSE_MODE` + * accepts `audit` or `enforce` (any case); any other value throws. + */ + get purviewDlpResponseMode(): PurviewDlpResponseMode { + const override = this.toolingOverrides.purviewDlpResponseMode?.(); + const mode = override !== undefined + ? override + : process.env.A365_PURVIEW_DLP_RESPONSE_MODE?.trim().toLowerCase() || 'audit'; + if (!PURVIEW_DLP_RESPONSE_MODES.has(mode)) { + throw new Error("A365_PURVIEW_DLP_RESPONSE_MODE must be 'audit' or 'enforce'."); + } + return mode as PurviewDlpResponseMode; + } + /** * Returns the dev-mode bearer token for an MCP server by name. * Checks BEARER_TOKEN_ first, then falls back to BEARER_TOKEN. diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts index ac9bd608..e38de84d 100644 --- a/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { RuntimeConfigurationOptions } from '@microsoft/agents-a365-runtime'; +import type { PurviewDlpResponseMode } from '../purview/contracts'; /** * Tooling configuration options - extends runtime options. @@ -54,4 +55,39 @@ export type ToolingConfigurationOptions = RuntimeConfigurationOptions & { * it. Falls back to A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS env var, then 20000. */ defenderRtpMaxContentCharacters?: () => number; + /** + * Whether Microsoft Purview data loss prevention is enabled (`PurviewDlpClient`). + * Falls back to ENABLE_A365_PURVIEW_DLP env var; disabled by default. + */ + isPurviewDlpEnabled?: () => boolean; + /** + * Microsoft Graph base URL of the `processContent` call, an absolute https URL. Falls back to + * A365_PURVIEW_DLP_GRAPH_BASE_URL env var, then `https://graph.microsoft.com/v1.0`. + */ + purviewDlpGraphBaseUrl?: () => string; + /** + * OAuth scope of the Purview DLP token. Falls back to A365_PURVIEW_DLP_AUTHENTICATION_SCOPE env var, + * then `https://graph.microsoft.com/.default`. + */ + purviewDlpAuthenticationScope?: () => string; + /** + * Deadline in milliseconds of each Purview evaluation, token acquisition included. Falls back to + * A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS env var, then 10000. + */ + purviewDlpTimeoutMilliseconds?: () => number; + /** + * Whether a Purview evaluation that returns no verdict blocks the content. Falls back to + * A365_PURVIEW_DLP_FAIL_MODE env var (`closed`); defaults to false (fail open). + */ + purviewDlpFailClosed?: () => boolean; + /** + * Maximum characters of the text sent to Purview; longer text is cut and, unless Purview blocks it, + * follows the fail mode. Falls back to A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS env var, then 100000. + */ + purviewDlpMaxContentCharacters?: () => number; + /** + * How the agent's reply is handled: `audit` (sent without waiting, never blocked) or `enforce`. + * Falls back to A365_PURVIEW_DLP_RESPONSE_MODE env var, then `audit`. + */ + purviewDlpResponseMode?: () => PurviewDlpResponseMode; }; diff --git a/packages/agents-a365-tooling/src/index.ts b/packages/agents-a365-tooling/src/index.ts index 0492085e..ec928269 100644 --- a/packages/agents-a365-tooling/src/index.ts +++ b/packages/agents-a365-tooling/src/index.ts @@ -7,3 +7,4 @@ export * from './contracts'; export * from './models'; export * from './configuration'; export * from './defender'; +export * from './purview'; diff --git a/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts b/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts new file mode 100644 index 00000000..d8f1459c --- /dev/null +++ b/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts @@ -0,0 +1,718 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { ToolingConfiguration, defaultToolingConfigurationProvider } from '../configuration'; +import { + PurviewDlpAccessToken, + PurviewDlpActivity, + PurviewDlpAgentContext, + PurviewDlpDecision, + PurviewDlpEvaluationResult, + PurviewDlpTokenResolver, +} from './contracts'; +import { + createDeadline, + cut, + describeError, + isNonNegativeInteger, + isObject, + networkErrorCode, + nonEmpty, + parseHttpsUrl, + readExpiry, + sdkError, + tokenCacheKeyOf, + trimTrailingSlashes, + untilAborted, + wellFormed, +} from './internal'; + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +type JsonObject = { [key: string]: Json }; + +const ACTIVITIES: ReadonlySet = new Set(['uploadText', 'downloadText']); +const PROCESS_CONTENT_PATH = '/dataSecurityAndGovernance/processContent'; +const DEFAULT_AGENT_VERSION = '1.0'; +const BLOCK = 'block'; +const BLOCK_ACCESS = 'blockaccess'; +const MAX_CACHED_TOKENS = 100; +const MAX_TRACKED_SESSIONS = 1000; +const TOKEN_REFRESH_SKEW_MILLISECONDS = 5 * 60 * 1000; + +const FAIL_CLOSED_REASON = 'Data loss prevention validation is unavailable and this agent is configured to fail closed.'; +const TRUNCATED_FAIL_CLOSED_REASON = + 'The content is too long to be fully validated by Microsoft Purview, and this agent is configured to fail closed.'; +const BLOCK_REASONS: Readonly> = { + uploadText: 'The request was blocked by a Microsoft Purview data loss prevention policy.', + downloadText: 'The response was blocked by a Microsoft Purview data loss prevention policy.', +}; + +/** Options for {@link PurviewDlpClient}. */ +export interface PurviewDlpClientOptions { + /** The configuration source; defaults to `defaultToolingConfigurationProvider` (environment variables). */ + configProvider?: IConfigurationProvider; + /** The fetch implementation; defaults to the global `fetch`. */ + fetchImplementation?: typeof fetch; + /** Creates request ids (tests); defaults to `crypto.randomUUID`. */ + idFactory?: () => string; + /** The clock in epoch milliseconds (tests); defaults to `Date.now`. */ + now?: () => number; +} + +interface CachedToken { + token: PurviewDlpAccessToken; + expiresAtMilliseconds: number; +} + +interface ResolvedToken { + token: PurviewDlpAccessToken; + expiresAtMilliseconds?: number; +} + +/** The agent identity and conversation of one evaluation, with every default applied. */ +interface Identity { + agentId: string; + sessionId: string; + sequence: number; + blueprintId?: string; + applicationId: string; + agentName: string; + agentVersion: string; +} + +/** + * Client for Microsoft Purview data loss prevention (DLP) through the Microsoft Graph `processContent` API + * (`POST {graph}/me/dataSecurityAndGovernance/processContent`, or `/users/{id}/...`). + * + * {@link evaluate} sends the text of a prompt (`uploadText`) or a reply (`downloadText`) to Purview, which + * applies the tenant's DLP policies for the agent's application and records the interaction for audit, and + * returns whether the content may proceed: a policy action with `restrictionAction: block` blocks it. Each + * call carries a new `client-request-id` and a Microsoft Graph token from a {@link PurviewDlpTokenResolver}, + * for example the agentic user's (`PurviewDlpTokenResolvers.fromAgenticUser`). + */ +export class PurviewDlpClient { + /** The header Microsoft Graph logs each request under. */ + public static readonly CLIENT_REQUEST_ID_HEADER = 'client-request-id'; + + private readonly configProvider: IConfigurationProvider; + private readonly fetchImplementation?: typeof fetch; + private readonly idFactory: () => string; + private readonly now: () => number; + private readonly tokens = new Map(); + private readonly inFlightTokens = new Map>(); + private readonly sequences = new Map(); + /** The highest sequence of a session no longer tracked: a session seen again resumes above it. */ + private evictedSequence = -1; + + /** + * @param options The configuration source and test seams. + * @throws When Purview DLP is enabled but the configuration cannot be used (for example the Graph base + * URL is not an absolute https URL). + */ + constructor(options: PurviewDlpClientOptions = {}) { + this.configProvider = options.configProvider ?? defaultToolingConfigurationProvider; + this.fetchImplementation = options.fetchImplementation; + this.idFactory = options.idFactory ?? randomUUID; + this.now = options.now ?? Date.now; + PurviewDlpClient.validate(this.configuration); + } + + /** The configuration this client uses. */ + public get configuration(): ToolingConfiguration { + return this.configProvider.getConfiguration(); + } + + /** + * Whether the value is a Purview activity this client evaluates. + * + * @param activity The activity, for example `uploadText`. + * @returns True for `uploadText` and `downloadText`. + */ + public static isActivity(activity: unknown): activity is PurviewDlpActivity { + return typeof activity === 'string' && ACTIVITIES.has(activity); + } + + /** + * Evaluates text with Purview DLP. One deadline (the configured timeout) covers the token acquisition + * and the request. + * + * Text longer than `purviewDlpMaxContentCharacters` is cut and sent with `isTruncated: true`, so Purview + * sees only its beginning. A block still stands, but an allow does not cover the rest: the result is + * marked `truncated`, and `allowed` follows the fail mode. + * + * @param activity `uploadText` for a prompt, `downloadText` for a reply. + * @param text The text to evaluate. + * @param agent The agent identity and conversation (`sessionId` is required). + * @param tokenResolver Resolves the Microsoft Graph token, and the user to evaluate for. + * @param signal Cancels the evaluation; the returned promise then rejects with its reason. + * @returns The result, or null without a call when Purview DLP is disabled or the text is empty. When + * no verdict is obtained (token, transport, timeout, HTTP or processing failure) the result is not + * evaluated and follows the configured fail mode. + * @throws When the arguments are invalid (for example no `agentId` or `sessionId`), or the Graph base URL + * is not an absolute https URL. + */ + public async evaluate( + activity: PurviewDlpActivity, + text: string, + agent: PurviewDlpAgentContext, + tokenResolver: PurviewDlpTokenResolver, + signal?: AbortSignal, + ): Promise { + const configuration = this.configuration; + if (!configuration.isPurviewDlpEnabled) { + return null; + } + + if (!PurviewDlpClient.isActivity(activity)) { + throw new TypeError("activity must be 'uploadText' or 'downloadText'."); + } + + if (typeof text !== 'string') { + throw new TypeError('text must be a string.'); + } + + PurviewDlpClient.requireArguments(agent, tokenResolver); + if (text.trim().length === 0) { + return null; + } + + const identity = this.identity(agent); + const baseUrl = PurviewDlpClient.graphBaseUrl(configuration); + signal?.throwIfAborted(); + const maxCharacters = configuration.purviewDlpMaxContentCharacters; + const truncated = text.length > maxCharacters; + const data = wellFormed(truncated ? cut(text, maxCharacters) : text); + // One id per call: the client-request-id, and the content entry's identifier in Purview. + const requestId = this.idFactory(); + const started = this.now(); + const deadline = createDeadline(configuration.purviewDlpTimeoutMilliseconds, signal); + const complete = (result: PurviewDlpEvaluationResult): PurviewDlpEvaluationResult => + truncated ? PurviewDlpClient.ofTruncatedContent(result, maxCharacters, configuration) : result; + try { + let token: PurviewDlpAccessToken; + try { + token = await untilAborted(this.getAccessToken(agent, tokenResolver, configuration, deadline.signal), deadline.signal); + } catch (error) { + if (signal?.aborted) { + throw signal.reason; + } + + const detail = deadline.signal.aborted ? 'timeout' : describeError(error); + return complete(this.failure(activity, requestId, `token unavailable: ${detail}`, undefined, started, configuration)); + } + + const body = this.body(activity, data, truncated, identity, requestId); + const url = `${baseUrl}${token.userId ? `/users/${encodeURIComponent(token.userId)}` : '/me'}${PROCESS_CONTENT_PATH}`; + return complete(await this.post(url, body, activity, requestId, token.accessToken, started, configuration, deadline.signal, signal)); + } finally { + deadline.dispose(); + } + } + + /** + * A result for an evaluation that could not be made (for example no agent identity): it follows the + * configured fail mode, like a transport failure. + * + * @param activity The Purview activity. + * @param error Why no verdict was obtained. + * @param latencyMilliseconds Time spent before the failure. + * @returns The not-evaluated result. + */ + public unavailable(activity: PurviewDlpActivity, error: string, latencyMilliseconds = 0): PurviewDlpEvaluationResult { + return this.result(activity, this.idFactory(), error, undefined, latencyMilliseconds, this.configuration); + } + + // ---- request ----------------------------------------------------------------------------- + + private identity(agent: PurviewDlpAgentContext): Identity { + const agentId = nonEmpty(agent.agentId); + if (!agentId) { + throw new TypeError('agent.agentId is required.'); + } + + const sessionId = nonEmpty(agent.sessionId); + if (!sessionId) { + throw new TypeError('agent.sessionId is required.'); + } + + const blueprintId = nonEmpty(agent.blueprintId); + return { + agentId, + sessionId, + sequence: isNonNegativeInteger(agent.sequence) ? agent.sequence : this.nextSequence(sessionId), + ...(blueprintId ? { blueprintId } : {}), + // DLP policies for an agent are scoped to its blueprint's application. + applicationId: nonEmpty(agent.applicationId) ?? blueprintId ?? agentId, + // Purview rejects a content entry without a name (inline, as a processing error). + agentName: nonEmpty(agent.agentName) ?? agentId, + agentVersion: nonEmpty(agent.agentVersion) ?? DEFAULT_AGENT_VERSION, + }; + } + + /** The `processContent` request: one conversation message from the agent's application. */ + private body(activity: PurviewDlpActivity, data: string, truncated: boolean, identity: Identity, requestId: string): JsonObject { + const timestamp = new Date(this.now()).toISOString(); + const agent: JsonObject = { + '@odata.type': 'microsoft.graph.aiAgentInfo', + identifier: identity.agentId, + ...(identity.blueprintId ? { blueprintId: identity.blueprintId } : {}), + name: identity.agentName, + version: identity.agentVersion, + }; + return { + contentToProcess: { + contentEntries: [ + { + '@odata.type': 'microsoft.graph.processConversationMetadata', + identifier: requestId, + content: { '@odata.type': 'microsoft.graph.textContent', data }, + name: `${identity.agentName} ${activity}`, + correlationId: identity.sessionId, + sequenceNumber: identity.sequence, + isTruncated: truncated, + createdDateTime: timestamp, + modifiedDateTime: timestamp, + contentCategory: 'ai', + agents: [agent], + }, + ], + activityMetadata: { activity }, + integratedAppMetadata: { name: identity.agentName, version: identity.agentVersion }, + protectedAppMetadata: { + name: identity.agentName, + version: identity.agentVersion, + applicationLocation: { + '@odata.type': 'microsoft.graph.policyLocationApplication', + value: identity.applicationId, + }, + }, + }, + }; + } + + /** + * Purview saw only the beginning of the text. A block still stands, but an allow does not cover the + * rest, so the content follows the fail mode instead, as if no verdict had been obtained: otherwise text + * padded past the limit would be authorized unseen. + */ + private static ofTruncatedContent( + result: PurviewDlpEvaluationResult, + maxCharacters: number, + configuration: ToolingConfiguration, + ): PurviewDlpEvaluationResult { + if (!result.evaluated || result.decision.blockAction) { + return { ...result, truncated: true }; + } + + const failClosed = configuration.purviewDlpFailClosed; + return { + ...result, + truncated: true, + allowed: !failClosed, + error: `content exceeded A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (${maxCharacters}); Purview evaluated a truncated copy`, + ...(failClosed ? { blockReason: TRUNCATED_FAIL_CLOSED_REASON } : {}), + }; + } + + // ---- transport --------------------------------------------------------------------------- + + private async post( + url: string, + body: JsonObject, + activity: PurviewDlpActivity, + requestId: string, + accessToken: string, + started: number, + configuration: ToolingConfiguration, + signal: AbortSignal, + callerSignal: AbortSignal | undefined, + ): Promise { + const fail = (error: string, httpStatus?: number): PurviewDlpEvaluationResult => + this.failure(activity, requestId, error, httpStatus, started, configuration); + + let status: number | undefined; + try { + let response: Response; + try { + response = await this.fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + [PurviewDlpClient.CLIENT_REQUEST_ID_HEADER]: requestId, + }, + body: JSON.stringify(body), + // A redirect must not carry the content and token elsewhere: it fails like any transport error. + redirect: 'error', + signal, + }); + } catch (error) { + if (callerSignal?.aborted) { + throw callerSignal.reason; + } + + return fail(signal.aborted ? 'request timeout' : `request failed: ${networkErrorCode(error)}`); + } + + status = response.status; + // Accepted without an inline decision: Purview processes the content and applies no restriction. + if (status === 202 || status === 204) { + discard(response); + return this.evaluated(activity, requestId, { blockAction: false, actionCount: 0 }, undefined, status, started); + } + + if (!response.ok) { + // Only the status: the body can echo the request. + discard(response); + return fail(`http ${status}`, status); + } + + let text: string; + try { + text = await response.text(); + } catch (_error) { + if (callerSignal?.aborted) { + throw callerSignal.reason; + } + + return fail(signal.aborted ? 'request timeout' : 'response body could not be read', status); + } + + let payload: unknown; + try { + payload = JSON.parse(text); + } catch (_error) { + return fail('non-JSON response', status); + } + + const verdict = readVerdict(payload); + if ('error' in verdict) { + return fail(verdict.error, status); + } + + return this.evaluated(activity, requestId, verdict.decision, verdict.protectionScopeState, status, started); + } catch (error) { + if (callerSignal?.aborted) { + throw callerSignal.reason; + } + + // Anything else (for example an error type raised by a wrapping fetch) is not a verdict either. + return fail(signal.aborted ? 'request timeout' : `request failed: ${describeError(error)}`, status); + } + } + + private fetch(url: string, init: RequestInit): Promise { + return this.fetchImplementation ? this.fetchImplementation(url, init) : fetch(url, init); + } + + private evaluated( + activity: PurviewDlpActivity, + requestId: string, + decision: PurviewDlpDecision, + protectionScopeState: string | undefined, + httpStatus: number, + started: number, + ): PurviewDlpEvaluationResult { + return { + allowed: !decision.blockAction, + evaluated: true, + truncated: false, + activity, + correlationId: requestId, + decision, + ...(protectionScopeState ? { protectionScopeState } : {}), + httpStatus, + latencyMilliseconds: this.now() - started, + ...(decision.blockAction ? { blockReason: BLOCK_REASONS[activity] } : {}), + }; + } + + private failure( + activity: PurviewDlpActivity, + requestId: string, + error: string, + httpStatus: number | undefined, + started: number, + configuration: ToolingConfiguration, + ): PurviewDlpEvaluationResult { + return this.result(activity, requestId, error, httpStatus, this.now() - started, configuration); + } + + private result( + activity: PurviewDlpActivity, + requestId: string, + error: string, + httpStatus: number | undefined, + latencyMilliseconds: number, + configuration: ToolingConfiguration, + ): PurviewDlpEvaluationResult { + const failClosed = configuration.purviewDlpFailClosed; + return { + allowed: !failClosed, + evaluated: false, + truncated: false, + activity, + correlationId: requestId, + decision: { blockAction: false, actionCount: 0 }, + ...(httpStatus !== undefined ? { httpStatus } : {}), + latencyMilliseconds, + error, + ...(failClosed ? { blockReason: FAIL_CLOSED_REASON } : {}), + }; + } + + // ---- authentication ---------------------------------------------------------------------- + + /** + * The token for one evaluation. Tokens of the SDK's own resolvers (`fromAgenticUser`), which depend only + * on the agent identity and scope, are cached until five minutes before they expire, and concurrent + * evaluations share one acquisition. A host's resolver is asked every time, as its token may be for a + * user the agent context does not identify. + */ + private getAccessToken( + agent: PurviewDlpAgentContext, + tokenResolver: PurviewDlpTokenResolver, + configuration: ToolingConfiguration, + signal: AbortSignal, + ): Promise { + const scope = configuration.purviewDlpAuthenticationScope; + const cacheKey = tokenCacheKeyOf(tokenResolver); + if (!cacheKey) { + return this.resolveToken(agent, tokenResolver, scope, signal).then((resolved) => resolved.token); + } + + const key = cacheKey(agent, scope); + const cached = this.tokens.get(key); + const now = this.now(); + if (cached && now < cached.expiresAtMilliseconds - TOKEN_REFRESH_SKEW_MILLISECONDS) { + return Promise.resolve(cached.token); + } + + // One acquisition per key, bounded by the configured timeout and not tied to any single caller's + // deadline or cancellation. It leaves the in-flight map when it completes, and only a successful one + // is cached. + let acquisition = this.inFlightTokens.get(key); + if (!acquisition) { + const started = this.acquireToken(key, agent, tokenResolver, scope, configuration.purviewDlpTimeoutMilliseconds); + acquisition = started.finally(() => { + if (this.inFlightTokens.get(key) === acquisition) { + this.inFlightTokens.delete(key); + } + }); + acquisition.catch(() => undefined); + this.inFlightTokens.set(key, acquisition); + } + + // Within five minutes of expiry the cached token is still valid: evaluations use it while the refresh + // runs, so a slow or failed early refresh neither delays nor fails them. + if (cached && now < cached.expiresAtMilliseconds) { + return Promise.resolve(cached.token); + } + + return acquisition.then((resolved) => resolved.token); + } + + private async acquireToken( + key: string, + agent: PurviewDlpAgentContext, + tokenResolver: PurviewDlpTokenResolver, + scope: string, + timeoutMilliseconds: number, + ): Promise { + const resolved = await this.resolveToken(agent, tokenResolver, scope, AbortSignal.timeout(timeoutMilliseconds)); + const expiresAtMilliseconds = resolved.expiresAtMilliseconds; + if (expiresAtMilliseconds !== undefined) { + const now = this.now(); + if (this.tokens.size >= MAX_CACHED_TOKENS) { + for (const [cachedKey, cached] of this.tokens) { + if (cached.expiresAtMilliseconds <= now) { + this.tokens.delete(cachedKey); + } + } + + if (this.tokens.size >= MAX_CACHED_TOKENS) { + const [oldest] = [...this.tokens].sort((a, b) => a[1].expiresAtMilliseconds - b[1].expiresAtMilliseconds); + this.tokens.delete(oldest[0]); + } + } + + this.tokens.set(key, { token: resolved.token, expiresAtMilliseconds }); + } + + return resolved; + } + + /** Asks the resolver for a token, within `signal`, and checks what it returns. */ + private async resolveToken( + agent: PurviewDlpAgentContext, + tokenResolver: PurviewDlpTokenResolver, + scope: string, + signal: AbortSignal, + ): Promise { + const pending = Promise.resolve().then(() => tokenResolver(agent, [scope], signal)); + const value: unknown = await untilAborted(pending, signal); + const accessToken = isObject(value) ? value['accessToken'] : undefined; + if (typeof accessToken !== 'string' || !accessToken.trim()) { + throw sdkError(new Error('The Purview token resolver returned no token.')); + } + + const userId = isObject(value) ? value['userId'] : undefined; + if (userId !== undefined && userId !== null && (typeof userId !== 'string' || !userId.trim())) { + throw sdkError(new Error('The Purview token resolver returned a userId that is not a non-empty string.')); + } + + const expiresAtMilliseconds = readExpiry(accessToken); + if (expiresAtMilliseconds !== undefined && expiresAtMilliseconds <= this.now()) { + throw sdkError(new Error('The Purview token resolver returned an expired token.')); + } + + return { + token: { accessToken, ...(typeof userId === 'string' ? { userId: userId.trim() } : {}) }, + ...(expiresAtMilliseconds !== undefined ? { expiresAtMilliseconds } : {}), + }; + } + + // ---- helpers ----------------------------------------------------------------------------- + + /** + * The next sequence of a session whose agent context has none. The last 1000 sessions are tracked; a + * session seen again after it was dropped resumes above every sequence given to a dropped session, so its + * sequence keeps increasing. + */ + private nextSequence(sessionId: string): number { + const next = (this.sequences.get(sessionId) ?? this.evictedSequence) + 1; + this.sequences.delete(sessionId); + this.sequences.set(sessionId, next); + while (this.sequences.size > MAX_TRACKED_SESSIONS) { + const [oldest, sequence] = this.sequences.entries().next().value as [string, number]; + this.sequences.delete(oldest); + this.evictedSequence = Math.max(this.evictedSequence, sequence); + } + + return next; + } + + private static validate(configuration: ToolingConfiguration): void { + if (!configuration.isPurviewDlpEnabled) { + return; + } + + PurviewDlpClient.graphBaseUrl(configuration); + void configuration.purviewDlpFailClosed; + void configuration.purviewDlpTimeoutMilliseconds; + void configuration.purviewDlpMaxContentCharacters; + void configuration.purviewDlpResponseMode; + } + + /** + * The configured Graph base URL, built from its parsed origin and path, so the token is never sent in + * plaintext or anywhere the URL did not name. + */ + private static graphBaseUrl(configuration: ToolingConfiguration): string { + const url = parseHttpsUrl(configuration.purviewDlpGraphBaseUrl); + if (!url || url.username || url.password || url.search || url.hash) { + throw new Error('A365_PURVIEW_DLP_GRAPH_BASE_URL must be an absolute https URL without credentials, query or fragment.'); + } + + return trimTrailingSlashes(`${url.origin}${url.pathname}`); + } + + private static requireArguments(agent: PurviewDlpAgentContext, tokenResolver: PurviewDlpTokenResolver): void { + if (!isObject(agent)) { + throw new TypeError('agent is required.'); + } + + if (typeof tokenResolver !== 'function') { + throw new TypeError('tokenResolver is required.'); + } + } +} + +// ---- module helpers ---------------------------------------------------------------------------- + +/** + * Reads Purview's decision from a `processContent` response, checking the shape of every node it reads. A + * policy action blocks when its `restrictionAction` is `block` or its `action` is `blockAccess` (any case), as in + * Microsoft's own Purview integrations; a block stands even beside processing errors or malformed actions. + * Otherwise processing errors (Graph reports a permanent bad request inline, with HTTP 200), or a response + * without a well-formed list of policy actions, mean no verdict was obtained. + */ +function readVerdict(payload: unknown): { decision: PurviewDlpDecision; protectionScopeState?: string } | { error: string } { + if (!isObject(payload)) { + return { error: 'response was not a JSON object' }; + } + + const actions = payload['policyActions']; + let blocks = false; + let blocking: string | undefined; + let first: string | undefined; + let malformed = !Array.isArray(actions); + for (const action of Array.isArray(actions) ? actions : []) { + if (!isObject(action)) { + malformed = true; + continue; + } + + const restriction = optionalString(action['restrictionAction']); + const kind = optionalString(action['action']); + if (restriction === null || kind === null) { + malformed = true; + } + + if (restriction) { + first ??= restriction; + } + + if (restriction?.toLowerCase() === BLOCK || kind?.toLowerCase() === BLOCK_ACCESS) { + blocks = true; + blocking ??= restriction || undefined; + } + } + + const state = payload['protectionScopeState']; + const protectionScopeState = typeof state === 'string' && state ? state : undefined; + const restrictionAction = blocks ? blocking : first; + const decision: PurviewDlpDecision = { + blockAction: blocks, + ...(restrictionAction ? { restrictionAction } : {}), + actionCount: Array.isArray(actions) ? actions.length : 0, + }; + if (decision.blockAction) { + return { decision, ...(protectionScopeState ? { protectionScopeState } : {}) }; + } + + const errors = payload['processingErrors']; + if (errors !== undefined && errors !== null && !Array.isArray(errors)) { + return { error: 'response had processingErrors that are not a list' }; + } + + if (Array.isArray(errors) && errors.length > 0) { + return { error: `processing errors: ${errors.length}` }; + } + + if (malformed) { + return { error: Array.isArray(actions) ? 'response had a policy action of another shape' : 'response had no list of policy actions' }; + } + + return { decision, ...(protectionScopeState ? { protectionScopeState } : {}) }; +} + +/** A string member, undefined when it is absent, or null when it has another type. */ +function optionalString(value: unknown): string | undefined | null { + if (value === undefined || value === null) { + return undefined; + } + + return typeof value === 'string' ? value : null; +} + +/** Releases a response body that is not read. */ +function discard(response: Response): void { + try { + response.body?.cancel().catch(() => undefined); + } catch (_error) { + // The body is already released. + } +} diff --git a/packages/agents-a365-tooling/src/purview/PurviewDlpTokenResolvers.ts b/packages/agents-a365-tooling/src/purview/PurviewDlpTokenResolvers.ts new file mode 100644 index 00000000..ccb77c40 --- /dev/null +++ b/packages/agents-a365-tooling/src/purview/PurviewDlpTokenResolvers.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + PurviewDlpAccessTokenProvider, + PurviewDlpAgentContext, + PurviewDlpAgenticUserConnection, + PurviewDlpTokenResolver, +} from './contracts'; +import { isObject, nonEmpty, sdkError, withTokenCacheKey } from './internal'; + +/** Token resolvers for `PurviewDlpClient`. */ +export class PurviewDlpTokenResolvers { + /** + * The agentic user's delegated Microsoft Graph token, from the agent's Agents SDK connection + * (`getAgenticUserToken`), so Purview evaluates the content as the agentic user (`/me`). The token needs + * `Content.Process.User`, which the agentic user inherits from its blueprint's delegated Microsoft Graph + * grant. The client caches it per tenant, agent, agentic user and scope until five minutes before it + * expires. + * + * The evaluation's agent context must carry `tenantId` (`activity.getAgenticTenantId()`), `agentId` + * (`activity.getAgenticInstanceId()`) and `agenticUserId` (`activity.getAgenticUser()`). + * + * @param connection The agent's connection, for example `adapter.connectionManager.getDefaultConnection()` + * (MSAL connections implement `getAgenticUserToken`). + * @returns A resolver for `PurviewDlpClient.evaluate`. + * @throws When the connection has no `getAgenticUserToken`. + */ + public static fromAgenticUser(connection: PurviewDlpAgenticUserConnection): PurviewDlpTokenResolver { + if (typeof connection?.getAgenticUserToken !== 'function') { + throw new TypeError('connection must provide getAgenticUserToken.'); + } + + const resolver: PurviewDlpTokenResolver = async (agent, scopes, signal) => { + signal?.throwIfAborted(); + const { tenantId, agentId, agenticUserId } = agenticIdentity(agent); + const accessToken = await connection.getAgenticUserToken(tenantId, agentId, agenticUserId, scopes); + if (typeof accessToken !== 'string' || !accessToken) { + throw sdkError(new Error('The agent connection returned no agentic user token.')); + } + + return { accessToken }; + }; + // The token is the agentic user's, fully determined by these ids and the scope, so it can be cached. + return withTokenCacheKey(resolver, (agent, scope) => { + const { tenantId, agentId, agenticUserId } = agenticIdentity(agent); + return JSON.stringify([tenantId.toLowerCase(), agentId.toLowerCase(), agenticUserId.toLowerCase(), scope]); + }); + } + + /** + * A Microsoft Graph token from the host, for example an on-behalf-of token for the signed-in user + * (`/me`, delegated `Content.Process.User`). With `userId`, the content is evaluated for that user + * (`/users/{userId}`), for example with an app-only token that carries `Content.Process.All`; the app-only + * path has not been validated end to end. The client does not cache these tokens: the provider is asked + * for every evaluation, so it should cache them itself. + * + * @param getToken Returns the token for the requested scopes. + * @param userId The user to evaluate the content for; unset for the token's own user (`/me`). + * @returns A resolver for `PurviewDlpClient.evaluate`. + * @throws When `getToken` is not a function, or `userId` is set but empty. + */ + public static fromAccessTokenProvider(getToken: PurviewDlpAccessTokenProvider, userId?: string): PurviewDlpTokenResolver { + if (typeof getToken !== 'function') { + throw new TypeError('getToken is required.'); + } + + if (userId !== undefined && !nonEmpty(userId)) { + throw new TypeError('userId must be a non-empty string when set.'); + } + + const user = nonEmpty(userId); + return async (agent, scopes, signal) => { + const accessToken = await getToken(scopes, signal, agent); + return typeof accessToken === 'string' && accessToken ? { accessToken, ...(user ? { userId: user } : {}) } : null; + }; + } +} + +function agenticIdentity(agent: PurviewDlpAgentContext): { tenantId: string; agentId: string; agenticUserId: string } { + const source: Record = isObject(agent) ? agent as unknown as Record : {}; + const tenantId = nonEmpty(source['tenantId']); + const agentId = nonEmpty(source['agentId']); + const agenticUserId = nonEmpty(source['agenticUserId']); + if (!tenantId) { + throw sdkError(new TypeError('agent.tenantId is required for the agentic user token.')); + } + + if (!agentId) { + throw sdkError(new TypeError('agent.agentId is required for the agentic user token.')); + } + + if (!agenticUserId) { + throw sdkError(new TypeError('agent.agenticUserId is required for the agentic user token.')); + } + + return { tenantId, agentId, agenticUserId }; +} diff --git a/packages/agents-a365-tooling/src/purview/contracts.ts b/packages/agents-a365-tooling/src/purview/contracts.ts new file mode 100644 index 00000000..649e82d7 --- /dev/null +++ b/packages/agents-a365-tooling/src/purview/contracts.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The Purview activity of the evaluated text: `uploadText` for content the user sends the agent (the + * prompt), `downloadText` for content the agent returns (the reply). + */ +export type PurviewDlpActivity = 'uploadText' | 'downloadText'; + +/** + * How the agent's reply (`downloadText`) is handled: `audit` sends it to Purview without waiting and + * never blocks it; `enforce` waits for Purview and blocks the reply like a prompt. + */ +export type PurviewDlpResponseMode = 'audit' | 'enforce'; + +/** + * Identity of the agent and the conversation an evaluation is for. + */ +export interface PurviewDlpAgentContext { + /** The agent identity (application) id, sent as `agents[0].identifier`. */ + agentId: string; + /** The agent's tenant, for token acquisition (required by `PurviewDlpTokenResolvers.fromAgenticUser`). */ + tenantId?: string; + /** + * The agentic user's id (`activity.getAgenticUser()`), for token acquisition (required by + * `PurviewDlpTokenResolvers.fromAgenticUser`). + */ + agenticUserId?: string; + /** + * The agent blueprint's application id (`activity.recipient.agenticAppBlueprintId`), sent as + * `agents[0].blueprintId`; left out when unknown. + */ + blueprintId?: string; + /** + * The application id Purview DLP policies are scoped to, sent as the `applicationLocation`. + * Defaults to {@link blueprintId}, then {@link agentId}. + */ + applicationId?: string; + /** The agent's display name; defaults to {@link agentId}. Purview requires a name. */ + agentName?: string; + /** The agent's version; defaults to `1.0`. */ + agentVersion?: string; + /** + * The conversation or session id, sent as the content entry's `correlationId`, which groups the + * conversation's messages in Purview. Required. + */ + sessionId?: string; + /** + * The position of the content in the conversation, sent as `sequenceNumber`. When unset, the + * client numbers each session's evaluations itself. + */ + sequence?: number; +} + +/** A Microsoft Graph access token for `processContent`, and the user whose content is evaluated. */ +export interface PurviewDlpAccessToken { + /** The access token. A delegated token needs `Content.Process.User`. */ + accessToken: string; + /** + * The user to evaluate the content for, as `POST /users/{userId}/...`; for example with an app-only + * token that carries `Content.Process.All`. When unset, the token's own user is used (`/me`), which + * requires a delegated token, for example the agentic user's. + */ + userId?: string; +} + +/** + * Resolves the Microsoft Graph token for a Purview evaluation, for example + * `PurviewDlpTokenResolvers.fromAgenticUser(connection)`. + * + * @param agent The agent identity and conversation. + * @param scopes The scopes to request (the configured Purview DLP scope). + * @param signal Aborts when the evaluation's deadline elapses. + * @returns The token, and the user to evaluate for (`/users/{userId}`), or nothing for `/me`. + * Returning nothing, or throwing, makes the evaluation follow the fail mode. + */ +export type PurviewDlpTokenResolver = ( + agent: PurviewDlpAgentContext, + scopes: string[], + signal: AbortSignal, +) => Promise | PurviewDlpAccessToken | null | undefined; + +/** + * Returns a Microsoft Graph access token, for `PurviewDlpTokenResolvers.fromAccessTokenProvider`. + * + * @param scopes The scopes to request. + * @param signal Aborts when the evaluation's deadline elapses. + * @param agent The agent identity and conversation. + */ +export type PurviewDlpAccessTokenProvider = ( + scopes: string[], + signal: AbortSignal, + agent: PurviewDlpAgentContext, +) => Promise | string | null | undefined; + +/** What Purview decided, read from the `policyActions` of the `processContent` response. */ +export interface PurviewDlpDecision { + /** + * Whether a policy action blocks the content: its `restrictionAction` is `block` or its `action` is + * `blockAccess`. + */ + blockAction: boolean; + /** + * The `restrictionAction` of the blocking action, when it has one; when nothing blocks, of the first action + * that has one (for example `warn` or `audit`). + */ + restrictionAction?: string; + /** How many policy actions Purview returned. */ + actionCount: number; +} + +/** + * The outcome of one Purview evaluation. `evaluated` is false when no verdict was obtained; `allowed` + * then follows the configured fail mode (`A365_PURVIEW_DLP_FAIL_MODE`). It also follows the fail mode + * when Purview allowed `truncated` text. + */ +export interface PurviewDlpEvaluationResult { + /** Whether the content may proceed. */ + allowed: boolean; + /** Whether Purview returned a verdict. */ + evaluated: boolean; + /** + * Whether the text was longer than `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` and was sent cut. A block + * stands; an allow does not cover the rest, so `allowed` then follows the fail mode and `error` says why. + */ + truncated: boolean; + /** The Purview activity that was evaluated. */ + activity: PurviewDlpActivity; + /** The `client-request-id` sent with the call; Microsoft Graph logs the request under it. */ + correlationId: string; + /** Purview's decision; empty when no verdict was obtained. */ + decision: PurviewDlpDecision; + /** Purview's `protectionScopeState` (`modified` or `notModified`), when returned. */ + protectionScopeState?: string; + /** The HTTP status, when a response was received. */ + httpStatus?: number; + /** Time spent on the evaluation, in milliseconds. */ + latencyMilliseconds: number; + /** + * Why the content could not be verified, for example `http 403` or `request timeout`. It names the + * failure or the exception type only, never a response body or token. + */ + error?: string; + /** A user-facing reason when the content is blocked. */ + blockReason?: string; +} + +/** + * The part of an Agents SDK connection (`AuthProvider`, for example the `MsalTokenProvider` from + * `connectionManager.getDefaultConnection()`) used to obtain the agentic user's token. + */ +export interface PurviewDlpAgenticUserConnection { + getAgenticUserToken(tenantId: string, agentAppInstanceId: string, agenticUserId: string, scopes: string[]): Promise; +} diff --git a/packages/agents-a365-tooling/src/purview/index.ts b/packages/agents-a365-tooling/src/purview/index.ts new file mode 100644 index 00000000..d3b3a919 --- /dev/null +++ b/packages/agents-a365-tooling/src/purview/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './contracts'; +export * from './PurviewDlpClient'; +export * from './PurviewDlpTokenResolvers'; diff --git a/packages/agents-a365-tooling/src/purview/internal.ts b/packages/agents-a365-tooling/src/purview/internal.ts new file mode 100644 index 00000000..6aa9255c --- /dev/null +++ b/packages/agents-a365-tooling/src/purview/internal.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { PurviewDlpAgentContext, PurviewDlpTokenResolver } from './contracts'; + +// Internal to the Purview module: not exported from the package. + +const MAX_ERROR_CHARACTERS = 200; +const ERROR_TYPE = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; + +/** Errors raised by the SDK itself, whose messages carry no token or response body. */ +const sdkErrors = new WeakSet(); + +/** Marks an error the SDK raises, so its message may be reported. */ +export function sdkError(error: T): T { + sdkErrors.add(error); + return error; +} + +/** + * The exception type (for example `TypeError`) and, for the SDK's own errors, the message. Nothing else of + * another exception is reported, as it may carry a token or a response body. + */ +export function describeError(error: unknown): string { + if (typeof error !== 'object' || error === null) { + return 'unknown error'; + } + + const name = (error as { name?: unknown }).name; + const type = typeof name === 'string' && ERROR_TYPE.test(name) ? name : 'Error'; + return sdkErrors.has(error) ? singleLine(`${type}: ${(error as Error).message}`) : type; +} + +/** The cache key of a token resolver whose token depends only on the agent identity and scope. */ +export type TokenCacheKey = (agent: PurviewDlpAgentContext, scope: string) => string; + +const tokenCacheKeys = new WeakMap(); + +/** + * Lets the client cache the resolver's tokens under `key`. Only resolvers whose token is fully determined + * by the key may be cached: a host's resolver may return a token for a user the agent context does not + * identify, so it is never cached. + */ +export function withTokenCacheKey(resolver: PurviewDlpTokenResolver, key: TokenCacheKey): PurviewDlpTokenResolver { + tokenCacheKeys.set(resolver, key); + return resolver; +} + +export function tokenCacheKeyOf(resolver: PurviewDlpTokenResolver): TokenCacheKey | undefined { + return tokenCacheKeys.get(resolver); +} + +/** A deadline for one evaluation that also aborts when the caller's signal aborts. */ +export function createDeadline(timeoutMilliseconds: number, callerSignal?: AbortSignal): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error('The Purview evaluation timed out.')), timeoutMilliseconds); + const onCallerAbort = (): void => controller.abort(callerSignal?.reason); + callerSignal?.addEventListener('abort', onCallerAbort, { once: true }); + return { + signal: controller.signal, + dispose: () => { + clearTimeout(timer); + callerSignal?.removeEventListener('abort', onCallerAbort); + }, + }; +} + +/** Settles like `promise`, or rejects with the signal's reason when it aborts first. */ +export function untilAborted(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason); + } + + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + +/** The system error code of a failed fetch (for example `ECONNREFUSED`), or the exception type. */ +export function networkErrorCode(error: unknown): string { + const cause = isObject(error) ? error['cause'] : undefined; + const code = isObject(cause) && typeof cause['code'] === 'string' && ERROR_TYPE.test(cause['code']) ? cause['code'] : undefined; + return code ?? describeError(error); +} + +/** The `exp` of a JWT in epoch milliseconds, or undefined when the token is not a JWT with `exp`. */ +export function readExpiry(token: string): number | undefined { + const parts = token.split('.'); + if (parts.length !== 3) { + return undefined; + } + + try { + const payload: unknown = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + const exp = isObject(payload) ? payload['exp'] : undefined; + return typeof exp === 'number' && Number.isFinite(exp) ? Math.floor(exp) * 1000 : undefined; + } catch (_error) { + return undefined; + } +} + +/** + * `value` with every lone surrogate replaced by U+FFFD. `JSON.stringify` would write a lone surrogate as a + * `\uD8xx` escape, which strict JSON parsers reject, failing the request. + */ +export function wellFormed(value: string): string { + const native = (value as unknown as { toWellFormed?: () => string }).toWellFormed; + if (typeof native === 'function') { + return native.call(value); + } + + let result = ''; + let start = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code < 0xd800 || code > 0xdfff) { + continue; + } + + const next = value.charCodeAt(index + 1); + if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { + index += 1; + continue; + } + + result += `${value.slice(start, index)}\uFFFD`; + start = index + 1; + } + + return start === 0 ? value : result + value.slice(start); +} + +/** The first `maxCharacters` characters of `value`, one fewer when that would split a surrogate pair. */ +export function cut(value: string, maxCharacters: number): string { + const code = value.charCodeAt(maxCharacters - 1); + return value.slice(0, code >= 0xd800 && code <= 0xdbff ? maxCharacters - 1 : maxCharacters); +} + +/** `value` parsed as an absolute https URL with a host (`https:host` reads as `https://host`), or undefined. */ +export function parseHttpsUrl(value: string): URL | undefined { + try { + const url = new URL(value); + return url.protocol === 'https:' && url.hostname ? url : undefined; + } catch (_error) { + return undefined; + } +} + +/** Removes trailing slashes in linear time (a `/+$` pattern is polynomial). */ +export function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === '/') { + end -= 1; + } + + return value.slice(0, end); +} + +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** A trimmed, well formed, non-empty string, or undefined. */ +export function nonEmpty(value: unknown): string | undefined { + const text = typeof value === 'string' ? wellFormed(value.trim()) : ''; + return text || undefined; +} + +export function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function singleLine(value: string): string { + const line = value.replace(/\s+/g, ' ').trim(); + return line.length > MAX_ERROR_CHARACTERS ? `${line.slice(0, MAX_ERROR_CHARACTERS)}...` : line; +} diff --git a/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts b/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts new file mode 100644 index 00000000..c376f199 --- /dev/null +++ b/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts @@ -0,0 +1,788 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { runInNewContext } from 'node:vm'; +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { AgentContext, AgentContextBuilder, Interceptor, JsonValue, proceeds } from '@responsibleai/agent-hooks'; +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { + DefenderRtpClient, + PurviewDlpAgentContext, + PurviewDlpClient, + PurviewDlpEvaluationResult, + PurviewDlpTokenResolver, + ToolingConfiguration, + ToolingConfigurationOptions, +} from '@microsoft/agents-a365-tooling'; +import { + A365DefenderInterceptor, + A365PurviewCall, + A365PurviewInterceptor, + addA365Defender, + addA365Purview, + createProtectionEmitter, +} from '../../packages/agents-a365-tooling-extensions-agenthooks/src'; +import { + AGENTIC_USER_ID, + AGENT_ID, + BLOCK_ACTION, + BLUEPRINT_ID, + GRAPH_BASE_URL, + PURVIEW_ENVIRONMENT_VARIABLES, + TENANT_ID, + createGraphToken, + fakeGraph, + json, + processed, + sentText, + waitForAbort, +} from '../tooling/fixtures/purview'; +import { DEFENDER_ENVIRONMENT_VARIABLES, ENDPOINT, createToken, fakeEndpoint } from '../tooling/fixtures/defender'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const originalEnv = process.env; + +beforeEach(() => { + process.env = { ...originalEnv }; + for (const name of [...PURVIEW_ENVIRONMENT_VARIABLES, ...DEFENDER_ENVIRONMENT_VARIABLES]) delete process.env[name]; +}); + +afterEach(() => { + process.env = originalEnv; +}); + +const CARD = 'Please charge my card 4111 1111 1111 1111 for the hotel.'; +const BLOCKED_REQUEST = 'The request was blocked by a Microsoft Purview data loss prevention policy.'; +const BLOCKED_RESPONSE = 'The response was blocked by a Microsoft Purview data loss prevention policy.'; +const FAIL_CLOSED_REASON = 'Data loss prevention validation is unavailable and this agent is configured to fail closed.'; +const AGENT: PurviewDlpAgentContext = { agentId: AGENT_ID, tenantId: TENANT_ID, agenticUserId: AGENTIC_USER_ID, blueprintId: BLUEPRINT_ID }; +const tokenResolver: PurviewDlpTokenResolver = async () => ({ accessToken: createGraphToken() }); + +interface HarnessOptions { + configuration?: ToolingConfigurationOptions; + resolveCall?: (context: AgentContext) => A365PurviewCall | null | undefined | Promise; + onEvaluated?: (result: PurviewDlpEvaluationResult) => void; +} + +/** The Purview interceptor under the real agent-hooks emitter, against a fake Microsoft Graph. */ +function harness( + respond: (body: Record, init: RequestInit) => Response | Promise, + options: HarnessOptions = {}, +) { + const graph = fakeGraph(respond); + const configProvider = new DefaultConfigurationProvider(() => new ToolingConfiguration({ + isPurviewDlpEnabled: () => true, + purviewDlpGraphBaseUrl: () => GRAPH_BASE_URL, + ...options.configuration, + })); + const client = new PurviewDlpClient({ configProvider, fetchImplementation: graph.fetch }); + const evaluations: PurviewDlpEvaluationResult[] = []; + let resolved = 0; + const emitter = addA365Purview( + createProtectionEmitter({ configProvider }), + new A365PurviewInterceptor( + client, + options.resolveCall ?? (() => { + resolved += 1; + return { agent: AGENT, tokenResolver }; + }), + options.onEvaluated ?? ((result) => evaluations.push(result)), + ), + ); + return { emitter, calls: graph.calls, evaluations, resolvedCount: () => resolved }; +} + +const builder = (sessionId: string, agentName?: string): AgentContextBuilder => + new AgentContextBuilder({ agentId: AGENT_ID, framework: 'agent-framework', sessionId, agentName }); + +/** Waits until the evaluation listener, which runs after the verdict is returned, has run. */ +const settle = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +/** Waits until `count` evaluations have reached the listener. */ +async function evaluated(evaluations: PurviewDlpEvaluationResult[], count = 1): Promise { + for (let attempt = 0; attempt < 200 && evaluations.length < count; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +const blockCard = (body: Record): Response => sentText({ body } as never).includes('4111') + ? processed([BLOCK_ACTION]) + : processed(); + +describe('A365PurviewInterceptor under the agent-hooks emitter', () => { + it('evaluates the user\'s message as uploadText and allows it', async () => { + const { emitter, calls, evaluations } = harness(blockCard); + + const record = await emitter.emitUnchecked(builder('conversation-1', 'SampleAgent').input('Find flights to Paris')); + await settle(); + + expect(proceeds(record)).toBe(true); + expect(record.verdict).toEqual({ decision: 'allow' }); + expect(record.composition.profile).toBe('parallel/strictest'); + expect(record.verdicts?.[0]?.name).toBe('purview'); + expect(calls).toHaveLength(1); + const request = calls[0].body.contentToProcess; + const entry = request.contentEntries[0]; + expect(sentText(calls[0])).toBe('Find flights to Paris'); + expect(request.activityMetadata).toEqual({ activity: 'uploadText' }); + expect(entry.correlationId).toBe('conversation-1'); + expect(entry.sequenceNumber).toBe(record.sequence); + expect(entry.name).toBe('SampleAgent uploadText'); + expect(entry.agents[0]).toMatchObject({ identifier: AGENT_ID, blueprintId: BLUEPRINT_ID, name: 'SampleAgent' }); + expect(request.protectedAppMetadata.applicationLocation.value).toBe(BLUEPRINT_ID); + expect(evaluations).toHaveLength(1); + expect(evaluations[0]).toMatchObject({ activity: 'uploadText', allowed: true, evaluated: true, correlationId: calls[0].clientRequestId }); + }); + + it('blocks a message a Purview DLP policy restricts', async () => { + const { emitter, calls } = harness(blockCard); + + const record = await emitter.emitUnchecked(builder('conversation-2').input(CARD)); + + expect(proceeds(record)).toBe(false); + expect(record.verdict).toEqual({ + decision: 'deny', + reason: 'purview:block', + message: BLOCKED_REQUEST, + evidence: { + artefact: 'purview-verdict', + verification_pointers: { correlation: `urn:a365:purview:${calls[0].clientRequestId}` }, + }, + }); + expect(record.decided_by).toBe(0); + }); + + it('takes the session id and sequence from the context, and the agent name from the call first', async () => { + const { emitter, calls } = harness(blockCard, { + resolveCall: () => ({ agent: { ...AGENT, agentName: 'CallAgent', sessionId: 'ignored', sequence: 99 }, tokenResolver }), + }); + const turn = builder('conversation-3', 'ContextAgent'); + + await emitter.emitUnchecked(turn.agentStartup(['SearchFlights'])); + const record = await emitter.emitUnchecked(turn.input('hello')); + + const entry = calls[0].body.contentToProcess.contentEntries[0]; + expect(entry).toMatchObject({ correlationId: 'conversation-3', sequenceNumber: record.sequence, name: 'CallAgent uploadText' }); + expect(record.sequence).toBeGreaterThan(0); + }); + + describe('replies in the default audit mode', () => { + it('allows the reply at once and audits it in the background', async () => { + let respond: (response: Response) => void = () => undefined; + const { emitter, calls, evaluations } = harness(() => new Promise((resolve) => { + respond = resolve; + })); + + const started = Date.now(); + const record = await emitter.emitUnchecked(builder('conversation-4', 'SampleAgent').output(`Booked with ${CARD}`)); + const elapsed = Date.now() - started; + + expect(proceeds(record)).toBe(true); + expect(record.verdict).toEqual({ decision: 'allow' }); + expect(elapsed).toBeLessThan(500); + expect(evaluations).toHaveLength(0); + for (let attempt = 0; attempt < 100 && calls.length === 0; attempt += 1) await settle(); + expect(calls).toHaveLength(1); + expect(calls[0].body.contentToProcess.activityMetadata).toEqual({ activity: 'downloadText' }); + expect(calls[0].body.contentToProcess.contentEntries[0].sequenceNumber).toBe(record.sequence); + respond(processed([BLOCK_ACTION])); + await evaluated(evaluations); + expect(evaluations[0]).toMatchObject({ activity: 'downloadText', evaluated: true, allowed: false, decision: { blockAction: true } }); + }); + + it('never blocks the reply, even when failing closed and Purview is unavailable', async () => { + const { emitter, evaluations } = harness(() => json({}, 403), { configuration: { purviewDlpFailClosed: () => true } }); + + const record = await emitter.emitUnchecked(builder('conversation-5').output('Here are three flights.')); + await evaluated(evaluations); + + expect(proceeds(record)).toBe(true); + expect(evaluations[0]).toMatchObject({ activity: 'downloadText', evaluated: false, allowed: false, error: 'http 403' }); + }); + + it('bounds the background evaluation by the client timeout', async () => { + const { emitter, evaluations } = harness((_body, init) => waitForAbort(init), { + configuration: { purviewDlpTimeoutMilliseconds: () => 100 }, + }); + + const record = await emitter.emitUnchecked(builder('conversation-6').output('Here are three flights.')); + await evaluated(evaluations); + + expect(proceeds(record)).toBe(true); + expect(evaluations[0]).toMatchObject({ evaluated: false, error: 'request timeout' }); + }); + + it('contains every failure of the background evaluation', async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + const throwing = harness(processed, { + resolveCall: () => { + throw new Error('no turn state for token secret-value'); + }, + }); + const nothing = harness(processed, { resolveCall: () => null }); + const brokenListener = harness(processed, { + onEvaluated: () => { + throw new Error('logger failed'); + }, + }); + const rejectingListener = harness(processed, { + onEvaluated: () => runInNewContext('Promise.reject(new Error("logger failed"))'), + }); + + const records = [ + await throwing.emitter.emitUnchecked(builder('c-7').output('reply')), + await nothing.emitter.emitUnchecked(builder('c-7').output('reply')), + await brokenListener.emitter.emitUnchecked(builder('c-7').output('reply')), + await rejectingListener.emitter.emitUnchecked(builder('c-7').output('reply')), + ]; + await evaluated(throwing.evaluations); + await evaluated(nothing.evaluations); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(records.every(proceeds)).toBe(true); + expect(throwing.evaluations[0]).toMatchObject({ evaluated: false, error: 'no agent identity was resolved: Error' }); + expect(nothing.evaluations[0]).toMatchObject({ evaluated: false, error: 'no agent identity was resolved' }); + expect(throwing.calls).toHaveLength(0); + expect(brokenListener.calls).toHaveLength(1); + } finally { + process.off('unhandledRejection', onUnhandled); + } + + expect(unhandled).toEqual([]); + }); + }); + + describe('replies in enforce mode', () => { + it('waits for Purview and blocks a reply it restricts', async () => { + const { emitter, calls } = harness(blockCard, { configuration: { purviewDlpResponseMode: () => 'enforce' } }); + const turn = builder('conversation-8'); + + const allowed = await emitter.emitUnchecked(turn.output('Here are three flights.')); + const denied = await emitter.emitUnchecked(turn.output(`Charged ${CARD}`)); + + expect(proceeds(allowed)).toBe(true); + expect(proceeds(denied)).toBe(false); + expect(denied.verdict).toMatchObject({ decision: 'deny', reason: 'purview:block', message: BLOCKED_RESPONSE }); + expect(calls.map((call) => call.body.contentToProcess.activityMetadata.activity)).toEqual(['downloadText', 'downloadText']); + }); + + it('follows the fail mode when no verdict is obtained', async () => { + const { emitter } = harness(() => json({}, 503), { + configuration: { purviewDlpResponseMode: () => 'enforce', purviewDlpFailClosed: () => true }, + }); + + const record = await emitter.emitUnchecked(builder('conversation-9').output('Here are three flights.')); + + expect(proceeds(record)).toBe(false); + expect(record.verdict.reason).toBe('runtime_error:purview_unverified'); + }); + }); + + it('allows with a warning when fail-open Purview is unavailable', async () => { + const { emitter } = harness(() => json({ error: { message: `echo ${CARD}` } }, 503)); + + const record = await emitter.emitUnchecked(builder('conversation-10').input(CARD)); + + expect(proceeds(record)).toBe(true); + expect(record.verdict.warnings).toEqual([{ reason: 'purview:unverified', message: 'http 503' }]); + }); + + it('blocks as unverified, not as a detection, when fail-closed Purview is unavailable', async () => { + const { emitter } = harness(() => json({ policyActions: [], processingErrors: [{ code: 'BadRequest' }] }), { + configuration: { purviewDlpFailClosed: () => true }, + }); + + const record = await emitter.emitUnchecked(builder('conversation-11').input('hello')); + + expect(proceeds(record)).toBe(false); + expect(record.verdict).toEqual({ + decision: 'deny', + reason: 'runtime_error:purview_unverified', + message: FAIL_CLOSED_REASON, + warnings: [{ reason: 'purview:unverified', message: 'processing errors: 1' }], + }); + }); + + it('applies the client timeout and fail mode before the emitter times out', async () => { + const { emitter } = harness((_body, init) => waitForAbort(init), { configuration: { purviewDlpTimeoutMilliseconds: () => 100 } }); + + const record = await emitter.emitUnchecked(builder('conversation-12').input('hello')); + + expect(proceeds(record)).toBe(true); + expect(record.verdict.warnings).toEqual([{ reason: 'purview:unverified', message: 'request timeout' }]); + }); + + it('follows the fail mode without a call when no identity is resolved', async () => { + const open = harness(processed, { resolveCall: () => undefined }); + const closed = harness(processed, { resolveCall: () => null, configuration: { purviewDlpFailClosed: () => true } }); + + const allowed = await open.emitter.emitUnchecked(builder('conversation-13').input('hello')); + const denied = await closed.emitter.emitUnchecked(builder('conversation-13').input('hello')); + await settle(); + + expect(proceeds(allowed)).toBe(true); + expect(allowed.verdict.warnings).toEqual([{ reason: 'purview:unverified', message: 'no agent identity was resolved' }]); + expect(proceeds(denied)).toBe(false); + expect(denied.verdict).toMatchObject({ reason: 'runtime_error:purview_unverified', message: FAIL_CLOSED_REASON }); + expect(open.calls).toHaveLength(0); + expect(closed.calls).toHaveLength(0); + expect(open.evaluations).toEqual([expect.objectContaining({ activity: 'uploadText', allowed: true, evaluated: false })]); + }); + + it('follows the fail mode when resolving the call fails, reporting only the error type', async () => { + const resolveCall = (): never => { + throw new Error('token secret-value'); + }; + const open = harness(processed, { resolveCall }); + const closed = harness(processed, { resolveCall, configuration: { purviewDlpFailClosed: () => true } }); + + const allowed = await open.emitter.emitUnchecked(builder('conversation-14').input('hello')); + const denied = await closed.emitter.emitUnchecked(builder('conversation-14').input('hello')); + + expect(allowed.verdict.warnings).toEqual([{ reason: 'purview:unverified', message: 'no agent identity was resolved: Error' }]); + expect(proceeds(denied)).toBe(false); + expect(denied.verdict.reason).toBe('runtime_error:purview_unverified'); + }); + + it('follows the fail mode when the identity is invalid', async () => { + const { emitter, calls } = harness(processed, { + resolveCall: () => ({ agent: { agentId: ' ' }, tokenResolver }), + configuration: { purviewDlpFailClosed: () => true }, + }); + const wrongShape = harness(processed, { resolveCall: () => ({ agent: 'agent' as never, tokenResolver }) }); + + const record = await emitter.emitUnchecked(builder('conversation-15').input('hello')); + const other = await wrongShape.emitter.emitUnchecked(builder('conversation-15').input('hello')); + + expect(proceeds(record)).toBe(false); + expect(record.verdict.warnings).toEqual([{ reason: 'purview:unverified', message: 'TypeError: agent.agentId is required.' }]); + expect(other.verdict.warnings).toEqual([{ reason: 'purview:unverified', message: 'TypeError: agent is required.' }]); + expect(calls).toHaveLength(0); + }); + + it('follows the fail mode for a context without a session id', async () => { + const graph = fakeGraph(processed); + const interceptor = new A365PurviewInterceptor( + new PurviewDlpClient({ + configProvider: new DefaultConfigurationProvider(() => new ToolingConfiguration({ + isPurviewDlpEnabled: () => true, + purviewDlpGraphBaseUrl: () => GRAPH_BASE_URL, + purviewDlpFailClosed: () => true, + })), + fetchImplementation: graph.fetch, + }), + () => ({ agent: AGENT, tokenResolver }), + ); + const context = { ...builder('conversation-16').input('hello'), session: 'conversation-16' } as unknown as AgentContext; + + const verdict = await interceptor.intercept(context); + + expect(verdict).toMatchObject({ + decision: 'deny', + reason: 'runtime_error:purview_unverified', + warnings: [{ reason: 'purview:unverified', message: 'TypeError: agent.sessionId is required.' }], + }); + expect(graph.calls).toHaveLength(0); + }); + + it('does not call Purview, or resolve the call, for other points', async () => { + const { emitter, calls, resolvedCount } = harness(() => processed([BLOCK_ACTION])); + const turn = builder('conversation-17'); + + const records = [ + await emitter.emitUnchecked(turn.agentStartup(['SearchFlights'])), + await emitter.emitUnchecked(turn.preModelCall('gpt-4o', [{ role: 'user', content: CARD }])), + await emitter.emitUnchecked(turn.postModelCall('gpt-4o', CARD, [], 'stop')), + await emitter.emitUnchecked(turn.preToolCall('call-1', 'Pay', { card: CARD })), + await emitter.emitUnchecked(turn.postToolCall('call-1', 'Pay', { card: CARD }, CARD)), + await emitter.emitUnchecked(turn.agentShutdown('completed')), + ]; + + expect(records.every(proceeds)).toBe(true); + expect(calls).toHaveLength(0); + expect(resolvedCount()).toBe(0); + }); + + it('allows without a call while Purview DLP is disabled', async () => { + const { emitter, calls, resolvedCount } = harness(() => processed([BLOCK_ACTION]), { + configuration: { isPurviewDlpEnabled: () => false }, + }); + + const records = [ + await emitter.emitUnchecked(builder('conversation-18').input(CARD)), + await emitter.emitUnchecked(builder('conversation-18').output(CARD)), + ]; + + expect(records.every(proceeds)).toBe(true); + expect(calls).toHaveLength(0); + expect(resolvedCount()).toBe(0); + }); + + it.each([ + ['an empty message', ''], + ['whitespace', ' \n '], + ['null content', null], + ['content without text', [{ flag: true, empty: '' }, null, []]], + ])('allows %s without resolving the call', async (_case, content) => { + const { emitter, calls, resolvedCount } = harness(() => processed([BLOCK_ACTION]), { + configuration: { purviewDlpFailClosed: () => true }, + }); + + const record = await emitter.emitUnchecked(builder('conversation-19').input(content as JsonValue)); + + expect(proceeds(record)).toBe(true); + expect(calls).toHaveLength(0); + expect(resolvedCount()).toBe(0); + }); + + it('evaluates the text of structured content, in order', async () => { + const { emitter, calls } = harness(blockCard); + + const record = await emitter.emitUnchecked(builder('conversation-20').input([ + { type: 'text', text: 'Charge my card' }, + { type: 'card', details: { number: 4111111111111111, holder: 'Sample User' } }, + ])); + + expect(proceeds(record)).toBe(false); + expect(sentText(calls[0])).toBe(['text', 'Charge my card', 'card', '4111111111111111', 'Sample User'].join('\n')); + }); + + it('reads structured content only as far as the limit, and follows the fail mode for the rest', async () => { + const { emitter, calls } = harness(blockCard, { + configuration: { purviewDlpMaxContentCharacters: () => 20, purviewDlpFailClosed: () => true }, + }); + const parts = Array.from({ length: 1000 }, (_value, index) => ({ type: 'text', text: `part ${index}` })); + + const record = await emitter.emitUnchecked(builder('conversation-21').input([...parts, { text: CARD }])); + + expect(proceeds(record)).toBe(false); + expect(record.verdict).toMatchObject({ + reason: 'runtime_error:purview_unverified', + message: 'The content is too long to be fully validated by Microsoft Purview, and this agent is configured to fail closed.', + }); + expect(sentText(calls[0])).toHaveLength(20); + expect(calls[0].body.contentToProcess.contentEntries[0].isTruncated).toBe(true); + }); + + it('keeps reading structured content whose text ends exactly at the limit, so what follows is not left out unseen', async () => { + const { emitter, calls } = harness((body) => sentText({ body } as never).includes('BLOCK') ? processed([BLOCK_ACTION]) : processed(), { + configuration: { purviewDlpMaxContentCharacters: () => 10, purviewDlpFailClosed: () => true }, + }); + + const exact = await emitter.emitUnchecked(builder('conversation-26').input(['abcde', 'fghi'])); + const longer = await emitter.emitUnchecked(builder('conversation-26').input(['abcde', 'fghi', { padding: '' }, 'BLOCK'])); + + expect(proceeds(exact)).toBe(true); + expect(sentText(calls[0])).toBe('abcde\nfghi'); + expect(proceeds(longer)).toBe(false); + expect(longer.verdict.reason).toBe('runtime_error:purview_unverified'); + expect(sentText(calls[1])).toBe('abcde\nfghi'); + expect(calls[1].body.contentToProcess.contentEntries[0].isTruncated).toBe(true); + }); + + it.each([false, true])( + 'follows the fail mode for structured content that is blank up to the limit and goes on (fail closed: %s)', + async (failClosed) => { + const { emitter, calls, resolvedCount } = harness(() => processed([BLOCK_ACTION]), { + configuration: { purviewDlpMaxContentCharacters: () => 10, purviewDlpFailClosed: () => failClosed }, + }); + const unverified = { + reason: 'purview:unverified', + message: 'content exceeded A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (10) before any text; Purview was not called', + }; + + const records = [ + await emitter.emitUnchecked(builder('conversation-27').input([' '.repeat(11), CARD])), + await emitter.emitUnchecked(builder('conversation-27').input([{ text: '\n'.repeat(11), type: 'text' }, { text: CARD, type: 'text' }])), + ]; + + for (const record of records) { + expect(proceeds(record)).toBe(!failClosed); + expect(record.verdict.warnings).toEqual([unverified]); + } + expect(calls).toHaveLength(0); + expect(resolvedCount()).toBe(0); + }, + ); + + it('evaluates a message that is blank up to the limit and goes on, as truncated', async () => { + const { emitter, calls } = harness(blockCard, { + configuration: { purviewDlpMaxContentCharacters: () => 10, purviewDlpFailClosed: () => true }, + }); + + const record = await emitter.emitUnchecked(builder('conversation-28').input(`${' '.repeat(11)}${CARD}`)); + + expect(proceeds(record)).toBe(false); + expect(record.verdict.reason).toBe('runtime_error:purview_unverified'); + expect(calls[0].body.contentToProcess.contentEntries[0].isTruncated).toBe(true); + }); + + it('keeps Purview\'s block of a message longer than the limit', async () => { + const { emitter, calls } = harness(blockCard, { configuration: { purviewDlpMaxContentCharacters: () => 60 } }); + + const record = await emitter.emitUnchecked(builder('conversation-22').input(`${CARD} ${'a'.repeat(1000)}`)); + + expect(proceeds(record)).toBe(false); + expect(record.verdict.reason).toBe('purview:block'); + expect(calls[0].body.contentToProcess.contentEntries[0].isTruncated).toBe(true); + }); + + it('reads content that refers to itself once', async () => { + const graph = fakeGraph(blockCard); + const content: Record = { text: CARD }; + content['self'] = content; + const context = { ...builder('conversation-23').input('placeholder'), input: { content, role: 'user' } } as unknown as AgentContext; + const interceptor = new A365PurviewInterceptor( + new PurviewDlpClient({ + configProvider: new DefaultConfigurationProvider(() => new ToolingConfiguration({ + isPurviewDlpEnabled: () => true, + purviewDlpGraphBaseUrl: () => GRAPH_BASE_URL, + })), + fetchImplementation: graph.fetch, + }), + () => ({ agent: AGENT, tokenResolver }), + ); + + const verdict = await interceptor.intercept(context); + + expect(verdict.decision).toBe('deny'); + expect(sentText(graph.calls[0])).toBe(CARD); + }); + + it('returns the verdict before the evaluation listener runs, so a slow listener cannot delay it', async () => { + const order: string[] = []; + const { emitter } = harness(processed, { + onEvaluated: () => { + order.push('listener'); + const until = Date.now() + 500; + while (Date.now() < until) { + // A slow, synchronous logger. + } + }, + }); + + const started = Date.now(); + const record = await emitter.emitUnchecked(builder('conversation-24').input('hello')); + const elapsed = Date.now() - started; + order.push('verdict'); + await settle(); + + expect(proceeds(record)).toBe(true); + expect(elapsed).toBeLessThan(500); + expect(order).toEqual(['verdict', 'listener']); + }); + + it('follows the fail mode when the configuration cannot be read', async () => { + let failClosed = true; + let maximum = 100; + const { emitter } = harness(processed, { + configuration: { + purviewDlpFailClosed: () => failClosed, + purviewDlpMaxContentCharacters: () => maximum, + }, + }); + maximum = 0; + + const denied = await emitter.emitUnchecked(builder('conversation-25').input('hello')); + failClosed = false; + const allowed = await emitter.emitUnchecked(builder('conversation-25').input('hello')); + + expect(proceeds(denied)).toBe(false); + expect(denied.verdict.warnings).toEqual([ + { reason: 'purview:unverified', message: 'Error: purviewDlpMaxContentCharacters must be a positive integer.' }, + ]); + expect(proceeds(allowed)).toBe(true); + }); +}); + +describe('Defender and Purview on one emitter', () => { + function protection( + defenderRespond: (body: Record) => Response, + purviewRespond: (body: Record) => Response, + ) { + const defenderEndpoint = fakeEndpoint(defenderRespond); + const graph = fakeGraph(purviewRespond); + const configProvider = new DefaultConfigurationProvider(() => new ToolingConfiguration({ + isDefenderRtpEnabled: () => true, + defenderRtpEndpoint: () => ENDPOINT, + isPurviewDlpEnabled: () => true, + purviewDlpGraphBaseUrl: () => GRAPH_BASE_URL, + })); + const emitter = createProtectionEmitter({ configProvider }); + addA365Defender(emitter, new A365DefenderInterceptor( + new DefenderRtpClient({ configProvider, fetchImplementation: defenderEndpoint.fetch }), + () => ({ agent: { agentId: AGENT_ID, tenantId: TENANT_ID }, tokenResolver: async () => createToken() }), + )); + addA365Purview(emitter, new A365PurviewInterceptor( + new PurviewDlpClient({ configProvider, fetchImplementation: graph.fetch }), + () => ({ agent: AGENT, tokenResolver }), + )); + return { emitter, defenderCalls: defenderEndpoint.calls, purviewCalls: graph.calls }; + } + + const defenderBlocksUrls = (body: Record): Response => JSON.stringify(body).includes('malicious.example.test') + ? json({ decision: 'deny', reason: 'prevention_blocked', message: 'Known malicious URL.' }) + : json({ decision: 'allow' }); + + it('allows a clean turn that both allow', async () => { + const { emitter, defenderCalls, purviewCalls } = protection(defenderBlocksUrls, blockCard); + const turn = builder('conversation-30'); + + const records = [ + await emitter.emitUnchecked(turn.input('Find flights to Paris')), + await emitter.emitUnchecked(turn.preToolCall('call-1', 'SearchFlights', { destination: 'CDG' })), + await emitter.emitUnchecked(turn.output('Here are three flights.')), + ]; + + expect(records.every(proceeds)).toBe(true); + expect(records[0].verdicts?.map((verdict) => `${verdict.name}:${verdict.decision}`)).toEqual(['defender:allow', 'purview:allow']); + expect(defenderCalls.map((call) => call.body.interception_point)).toEqual(['input', 'pre_tool_call', 'output']); + for (let attempt = 0; attempt < 100 && purviewCalls.length < 2; attempt += 1) await settle(); + expect(purviewCalls.map((call) => call.body.contentToProcess.activityMetadata.activity)).toEqual(['uploadText', 'downloadText']); + }); + + it('denies a prompt Purview blocks, and a tool call Defender blocks', async () => { + const { emitter } = protection(defenderBlocksUrls, blockCard); + const turn = builder('conversation-31'); + + const prompt = await emitter.emitUnchecked(turn.input(CARD)); + const toolCall = await emitter.emitUnchecked(turn.preToolCall('call-2', 'FetchPage', { url: 'https://malicious.example.test' })); + + expect(proceeds(prompt)).toBe(false); + expect(prompt.verdict.reason).toBe('purview:block'); + expect(prompt.verdicts?.map((verdict) => `${verdict.name}:${verdict.decision}`)).toEqual(['defender:allow', 'purview:deny']); + expect(proceeds(toolCall)).toBe(false); + expect(toolCall.verdict.reason).toBe('defender:block:prevention_blocked'); + expect(toolCall.verdicts?.map((verdict) => `${verdict.name}:${verdict.decision}`)).toEqual(['defender:deny', 'purview:allow']); + }); +}); + +describe('A365PurviewInterceptor.toVerdict', () => { + const base = { + activity: 'uploadText' as const, + correlationId: 'request id/1', + latencyMilliseconds: 5, + decision: { blockAction: false, actionCount: 0 }, + }; + + it('maps an evaluated allow and block', () => { + expect(A365PurviewInterceptor.toVerdict({ ...base, allowed: true, evaluated: true, truncated: false })).toEqual({ decision: 'allow' }); + expect(A365PurviewInterceptor.toVerdict({ + ...base, + allowed: false, + evaluated: true, + truncated: false, + decision: { blockAction: true, restrictionAction: 'block', actionCount: 1 }, + blockReason: 'Blocked.', + })).toEqual({ + decision: 'deny', + reason: 'purview:block', + message: 'Blocked.', + evidence: { artefact: 'purview-verdict', verification_pointers: { correlation: 'urn:a365:purview:request%20id%2F1' } }, + }); + }); + + it('keeps a block of truncated text and uses the default message for the activity', () => { + expect(A365PurviewInterceptor.toVerdict({ + ...base, + activity: 'downloadText', + allowed: false, + evaluated: true, + truncated: true, + decision: { blockAction: true, actionCount: 1 }, + })).toMatchObject({ decision: 'deny', reason: 'purview:block', message: BLOCKED_RESPONSE }); + }); + + it('maps an allow of truncated text, or no verdict, by the fail mode', () => { + expect(A365PurviewInterceptor.toVerdict({ ...base, allowed: true, evaluated: true, truncated: true, error: 'too long' })) + .toEqual({ decision: 'allow', warnings: [{ reason: 'purview:unverified', message: 'too long' }] }); + expect(A365PurviewInterceptor.toVerdict({ ...base, allowed: false, evaluated: true, truncated: true, error: 'too long', blockReason: 'Long.' })) + .toEqual({ + decision: 'deny', + reason: 'runtime_error:purview_unverified', + message: 'Long.', + warnings: [{ reason: 'purview:unverified', message: 'too long' }], + }); + expect(A365PurviewInterceptor.toVerdict({ ...base, allowed: false, evaluated: false, truncated: false })) + .toEqual({ + decision: 'deny', + reason: 'runtime_error:purview_unverified', + warnings: [{ reason: 'purview:unverified', message: 'no verdict was returned' }], + }); + }); + + it('requires a result', () => { + expect(() => A365PurviewInterceptor.toVerdict(undefined as never)).toThrow('result is required.'); + }); + + it('maps the interception points Purview evaluates', () => { + expect(['input', 'output', 'pre_tool_call', 'agent_startup', undefined].map(A365PurviewInterceptor.activityOf)) + .toEqual(['uploadText', 'downloadText', undefined, undefined, undefined]); + }); +}); + +describe('createProtectionEmitter with Purview', () => { + const timeoutOf = (emitter: unknown): number => (emitter as { timeoutMs: number }).timeoutMs; + const configuration = (options: ToolingConfigurationOptions) => new DefaultConfigurationProvider(() => new ToolingConfiguration({ + defenderRtpTimeoutMilliseconds: () => 1000, + ...options, + })); + + it('times interceptors out after the longer client timeout plus two seconds', () => { + expect(timeoutOf(createProtectionEmitter({ configProvider: configuration({}) }))).toBe(3000); + expect(timeoutOf(createProtectionEmitter({ + configProvider: configuration({ isPurviewDlpEnabled: () => true, purviewDlpTimeoutMilliseconds: () => 4000 }), + }))).toBe(6000); + expect(timeoutOf(createProtectionEmitter({ + configProvider: configuration({ isPurviewDlpEnabled: () => true, purviewDlpTimeoutMilliseconds: () => 500 }), + }))).toBe(3000); + }); + + it('ignores the Purview timeout while Purview DLP is disabled', () => { + const provider = configuration({ isPurviewDlpEnabled: () => false, purviewDlpTimeoutMilliseconds: () => 4000 }); + + expect(timeoutOf(createProtectionEmitter({ configProvider: provider }))).toBe(3000); + expect(() => createProtectionEmitter({ configProvider: provider, interceptorTimeoutMilliseconds: 2000 })).not.toThrow(); + }); + + it('reads the Purview timeout from its own configuration when given', () => { + const purviewConfigProvider = configuration({ isPurviewDlpEnabled: () => true, purviewDlpTimeoutMilliseconds: () => 5000 }); + + expect(timeoutOf(createProtectionEmitter({ configProvider: configuration({}), purviewConfigProvider }))).toBe(7000); + }); + + it('requires the interceptor timeout to exceed the Purview timeout when Purview DLP is enabled', () => { + const provider = configuration({ isPurviewDlpEnabled: () => true, purviewDlpTimeoutMilliseconds: () => 4000 }); + + expect(() => createProtectionEmitter({ configProvider: provider, interceptorTimeoutMilliseconds: 4000 })) + .toThrow('interceptorTimeoutMilliseconds (4000) must exceed the Purview timeout (4000 ms)'); + expect(() => createProtectionEmitter({ configProvider: provider, interceptorTimeoutMilliseconds: 4001 })).not.toThrow(); + }); + + it('fails a slow interceptor closed', async () => { + const slow: Interceptor = { intercept: () => new Promise((resolve) => setTimeout(() => resolve({ decision: 'allow' }), 500)) }; + const emitter = createProtectionEmitter({ + interceptorTimeoutMilliseconds: 50, + configProvider: configuration({ defenderRtpTimeoutMilliseconds: () => 10, isPurviewDlpEnabled: () => true, purviewDlpTimeoutMilliseconds: () => 20 }), + }).register(slow, 'slow'); + + const record = await emitter.emitUnchecked(builder('conversation-40').input('hello')); + + expect(proceeds(record)).toBe(false); + expect(record.verdict.reason).toBe('host_error:interceptor_timeout'); + }); + + it('requires an emitter and an interceptor', () => { + const client = new PurviewDlpClient(); + expect(() => addA365Purview(undefined as never, new A365PurviewInterceptor(client, () => null))).toThrow('emitter is required.'); + expect(() => addA365Purview(createProtectionEmitter(), undefined as never)).toThrow('interceptor is required.'); + expect(() => new A365PurviewInterceptor(client, undefined as never)).toThrow('resolveCall is required.'); + expect(() => new A365PurviewInterceptor(undefined as never, () => null)).toThrow('client is required.'); + }); +}); diff --git a/tests/tooling/configuration/PurviewDlpConfiguration.test.ts b/tests/tooling/configuration/PurviewDlpConfiguration.test.ts new file mode 100644 index 00000000..264123c7 --- /dev/null +++ b/tests/tooling/configuration/PurviewDlpConfiguration.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { + DEFAULT_PURVIEW_DLP_AUTHENTICATION_SCOPE, + DEFAULT_PURVIEW_DLP_GRAPH_BASE_URL, + PurviewDlpResponseMode, + ToolingConfiguration, +} from '../../../packages/agents-a365-tooling/src'; +import { PURVIEW_ENVIRONMENT_VARIABLES } from '../fixtures/purview'; + +describe('Purview DLP tooling configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + for (const name of PURVIEW_ENVIRONMENT_VARIABLES) delete process.env[name]; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('is disabled by default, fails open and audits replies', () => { + const configuration = new ToolingConfiguration(); + + expect(configuration.isPurviewDlpEnabled).toBe(false); + expect(configuration.purviewDlpFailClosed).toBe(false); + expect(configuration.purviewDlpResponseMode).toBe('audit'); + }); + + it('defaults to Microsoft Graph v1.0, the Graph scope, a 10 second timeout and 100000 characters', () => { + const configuration = new ToolingConfiguration(); + + expect(DEFAULT_PURVIEW_DLP_GRAPH_BASE_URL).toBe('https://graph.microsoft.com/v1.0'); + expect(DEFAULT_PURVIEW_DLP_AUTHENTICATION_SCOPE).toBe('https://graph.microsoft.com/.default'); + expect(configuration.purviewDlpGraphBaseUrl).toBe(DEFAULT_PURVIEW_DLP_GRAPH_BASE_URL); + expect(configuration.purviewDlpAuthenticationScope).toBe(DEFAULT_PURVIEW_DLP_AUTHENTICATION_SCOPE); + expect(configuration.purviewDlpTimeoutMilliseconds).toBe(10000); + expect(configuration.purviewDlpMaxContentCharacters).toBe(100000); + }); + + it('reads the settings from the environment', () => { + process.env.ENABLE_A365_PURVIEW_DLP = 'true'; + process.env.A365_PURVIEW_DLP_GRAPH_BASE_URL = ' https://graph.example.test/beta/ '; + process.env.A365_PURVIEW_DLP_AUTHENTICATION_SCOPE = ' https://graph.example.test/.default '; + process.env.A365_PURVIEW_DLP_FAIL_MODE = 'CLOSED'; + process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = '1500'; + process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = '100'; + process.env.A365_PURVIEW_DLP_RESPONSE_MODE = ' Enforce '; + + const configuration = new ToolingConfiguration(); + + expect(configuration.isPurviewDlpEnabled).toBe(true); + expect(configuration.purviewDlpGraphBaseUrl).toBe('https://graph.example.test/beta'); + expect(configuration.purviewDlpAuthenticationScope).toBe('https://graph.example.test/.default'); + expect(configuration.purviewDlpFailClosed).toBe(true); + expect(configuration.purviewDlpTimeoutMilliseconds).toBe(1500); + expect(configuration.purviewDlpMaxContentCharacters).toBe(100); + expect(configuration.purviewDlpResponseMode).toBe('enforce'); + }); + + it('prefers overrides to the environment', () => { + process.env.ENABLE_A365_PURVIEW_DLP = 'true'; + process.env.A365_PURVIEW_DLP_FAIL_MODE = 'closed'; + process.env.A365_PURVIEW_DLP_RESPONSE_MODE = 'enforce'; + process.env.A365_PURVIEW_DLP_GRAPH_BASE_URL = 'https://env.example.test/v1.0'; + + const configuration = new ToolingConfiguration({ + isPurviewDlpEnabled: () => false, + purviewDlpFailClosed: () => false, + purviewDlpGraphBaseUrl: () => 'https://override.example.test/v1.0/', + purviewDlpAuthenticationScope: () => 'https://override.example.test/.default', + purviewDlpTimeoutMilliseconds: () => 500, + purviewDlpMaxContentCharacters: () => 1000, + purviewDlpResponseMode: () => 'audit', + }); + + expect(configuration.isPurviewDlpEnabled).toBe(false); + expect(configuration.purviewDlpFailClosed).toBe(false); + expect(configuration.purviewDlpGraphBaseUrl).toBe('https://override.example.test/v1.0'); + expect(configuration.purviewDlpAuthenticationScope).toBe('https://override.example.test/.default'); + expect(configuration.purviewDlpTimeoutMilliseconds).toBe(500); + expect(configuration.purviewDlpMaxContentCharacters).toBe(1000); + expect(configuration.purviewDlpResponseMode).toBe('audit'); + }); + + it('falls back to the defaults for blank overrides and non-numeric values, and rejects non-positive ones', () => { + process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = 'soon'; + const configuration = new ToolingConfiguration({ + purviewDlpGraphBaseUrl: () => ' ', + purviewDlpAuthenticationScope: () => ' ', + }); + + expect(configuration.purviewDlpTimeoutMilliseconds).toBe(10000); + expect(configuration.purviewDlpGraphBaseUrl).toBe(DEFAULT_PURVIEW_DLP_GRAPH_BASE_URL); + expect(configuration.purviewDlpAuthenticationScope).toBe(DEFAULT_PURVIEW_DLP_AUTHENTICATION_SCOPE); + expect(() => new ToolingConfiguration({ purviewDlpTimeoutMilliseconds: () => 0 }).purviewDlpTimeoutMilliseconds) + .toThrow('purviewDlpTimeoutMilliseconds must be a positive integer of at most 2147481647.'); + expect(() => new ToolingConfiguration({ purviewDlpMaxContentCharacters: () => -1 }).purviewDlpMaxContentCharacters) + .toThrow('purviewDlpMaxContentCharacters must be a positive integer.'); + expect(() => new ToolingConfiguration({ purviewDlpMaxContentCharacters: () => 1.5 }).purviewDlpMaxContentCharacters) + .toThrow('purviewDlpMaxContentCharacters must be a positive integer.'); + }); + + it('rejects a timeout beyond the timer range, which would fire after 1 ms', () => { + expect(new ToolingConfiguration({ purviewDlpTimeoutMilliseconds: () => 2_147_481_647 }).purviewDlpTimeoutMilliseconds) + .toBe(2_147_481_647); + expect(() => new ToolingConfiguration({ purviewDlpTimeoutMilliseconds: () => 2_147_481_648 }).purviewDlpTimeoutMilliseconds) + .toThrow('purviewDlpTimeoutMilliseconds must be a positive integer of at most 2147481647.'); + + process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = '99999999999'; + expect(() => new ToolingConfiguration().purviewDlpTimeoutMilliseconds) + .toThrow('purviewDlpTimeoutMilliseconds must be a positive integer of at most 2147481647.'); + }); + + it.each([ + ['true', true], ['1', true], ['YES', true], [' on ', true], + ['false', false], ['0', false], ['No', false], ['off', false], ['', false], + ])('reads ENABLE_A365_PURVIEW_DLP=%j', (value, enabled) => { + process.env.ENABLE_A365_PURVIEW_DLP = value; + + expect(new ToolingConfiguration().isPurviewDlpEnabled).toBe(enabled); + }); + + it.each(['enabled', 'ture', '2', 'y'])('rejects ENABLE_A365_PURVIEW_DLP=%s rather than leaving protection off', (value) => { + process.env.ENABLE_A365_PURVIEW_DLP = value; + + expect(() => new ToolingConfiguration().isPurviewDlpEnabled) + .toThrow('ENABLE_A365_PURVIEW_DLP must be true or false (or 1/0, yes/no, on/off).'); + }); + + it.each([ + ['open', false], + [' Open ', false], + ['', false], + ['closed', true], + [' CLOSED ', true], + ])('reads A365_PURVIEW_DLP_FAIL_MODE=%j', (mode, failClosed) => { + process.env.A365_PURVIEW_DLP_FAIL_MODE = mode; + + expect(new ToolingConfiguration().purviewDlpFailClosed).toBe(failClosed); + }); + + it.each(['clsoed', 'fail-closed', 'true', 'block'])('rejects A365_PURVIEW_DLP_FAIL_MODE=%s rather than failing open', (mode) => { + process.env.A365_PURVIEW_DLP_FAIL_MODE = mode; + + expect(() => new ToolingConfiguration().purviewDlpFailClosed).toThrow("A365_PURVIEW_DLP_FAIL_MODE must be 'open' or 'closed'."); + }); + + it.each([ + ['audit', 'audit'], + [' AUDIT ', 'audit'], + ['', 'audit'], + ['enforce', 'enforce'], + ['Enforce', 'enforce'], + ])('reads A365_PURVIEW_DLP_RESPONSE_MODE=%j', (value, mode) => { + process.env.A365_PURVIEW_DLP_RESPONSE_MODE = value; + + expect(new ToolingConfiguration().purviewDlpResponseMode).toBe(mode); + }); + + it.each(['block', 'enforced', 'true', 'audit-only'])('rejects A365_PURVIEW_DLP_RESPONSE_MODE=%s', (value) => { + process.env.A365_PURVIEW_DLP_RESPONSE_MODE = value; + + expect(() => new ToolingConfiguration().purviewDlpResponseMode) + .toThrow("A365_PURVIEW_DLP_RESPONSE_MODE must be 'audit' or 'enforce'."); + }); + + it('rejects a response mode override that is not audit or enforce', () => { + const configuration = new ToolingConfiguration({ purviewDlpResponseMode: () => 'Enforce' as PurviewDlpResponseMode }); + + expect(() => configuration.purviewDlpResponseMode).toThrow("A365_PURVIEW_DLP_RESPONSE_MODE must be 'audit' or 'enforce'."); + }); +}); diff --git a/tests/tooling/fixtures/purview.ts b/tests/tooling/fixtures/purview.ts new file mode 100644 index 00000000..f00a2dd8 --- /dev/null +++ b/tests/tooling/fixtures/purview.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { ToolingConfiguration, ToolingConfigurationOptions } from '../../../packages/agents-a365-tooling/src'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export const GRAPH_BASE_URL = 'https://graph.example.test/v1.0'; +export const AGENT_ID = '11111111-1111-1111-1111-111111111111'; +export const TENANT_ID = '22222222-2222-2222-2222-222222222222'; +export const BLUEPRINT_ID = '33333333-3333-3333-3333-333333333333'; +export const AGENTIC_USER_ID = '44444444-4444-4444-4444-444444444444'; +export const GRAPH_SCOPE = 'https://graph.microsoft.com/.default'; +export const PROCESS_CONTENT_URL = `${GRAPH_BASE_URL}/me/dataSecurityAndGovernance/processContent`; + +export const PURVIEW_ENVIRONMENT_VARIABLES = [ + 'ENABLE_A365_PURVIEW_DLP', + 'A365_PURVIEW_DLP_GRAPH_BASE_URL', + 'A365_PURVIEW_DLP_AUTHENTICATION_SCOPE', + 'A365_PURVIEW_DLP_FAIL_MODE', + 'A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS', + 'A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS', + 'A365_PURVIEW_DLP_RESPONSE_MODE', +]; + +/** A provider for an enabled Purview configuration pointing at {@link GRAPH_BASE_URL}. */ +export function purviewConfiguration( + overrides: ToolingConfigurationOptions = {}, +): DefaultConfigurationProvider { + return new DefaultConfigurationProvider(() => new ToolingConfiguration({ + isPurviewDlpEnabled: () => true, + purviewDlpGraphBaseUrl: () => GRAPH_BASE_URL, + ...overrides, + })); +} + +/** An unsigned JWT that expires after `lifetimeSeconds`. */ +export function createGraphToken(lifetimeSeconds = 3600, claims: Record = {}): string { + const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url'); + const exp = Math.floor(Date.now() / 1000) + lifetimeSeconds; + return `${encode({ alg: 'none' })}.${encode({ exp, scp: 'Content.Process.User', ...claims })}.signature`; +} + +export function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +/** A `processContent` response with the given policy actions. */ +export function processed(policyActions: unknown[] = [], extra: Record = {}): Response { + return json({ protectionScopeState: 'notModified', policyActions, processingErrors: [], ...extra }); +} + +/** The policy action Purview returns for a DLP rule that blocks the content. */ +export const BLOCK_ACTION = { + '@odata.type': '#microsoft.graph.restrictAccessAction', + action: 'restrictAccess', + restrictionAction: 'block', +}; + +export interface RecordedGraphCall { + url: string; + method: string; + authorization: string | null; + clientRequestId: string | null; + contentType: string | null; + redirect: RequestRedirect | undefined; + raw: string; + body: Record; +} + +/** A fake Microsoft Graph `processContent` endpoint that records each request. */ +export function fakeGraph( + respond: (body: Record, init: RequestInit, url: string) => Response | Promise, +): { fetch: typeof fetch; calls: RecordedGraphCall[] } { + const calls: RecordedGraphCall[] = []; + const fetchImplementation = async (input: string | URL | Request, init: RequestInit = {}): Promise => { + const headers = new Headers(init.headers); + const raw = String(init.body ?? '{}'); + const body = JSON.parse(raw); + const url = String(input); + calls.push({ + url, + method: init.method ?? 'GET', + authorization: headers.get('authorization'), + clientRequestId: headers.get('client-request-id'), + contentType: headers.get('content-type'), + redirect: init.redirect, + raw, + body, + }); + return await respond(body, init, url); + }; + return { fetch: fetchImplementation as typeof fetch, calls }; +} + +/** The text of the request's only content entry. */ +export function sentText(call: RecordedGraphCall): string { + return call.body.contentToProcess.contentEntries[0].content.data; +} + +/** A response that never arrives: it fails only when the request is aborted. */ +export function waitForAbort(init: RequestInit): Promise { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }); +} diff --git a/tests/tooling/purview-dlp-client.test.ts b/tests/tooling/purview-dlp-client.test.ts new file mode 100644 index 00000000..01985205 --- /dev/null +++ b/tests/tooling/purview-dlp-client.test.ts @@ -0,0 +1,945 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { + PurviewDlpAgentContext, + PurviewDlpAgenticUserConnection, + PurviewDlpClient, + PurviewDlpTokenResolver, + PurviewDlpTokenResolvers, + ToolingConfigurationOptions, +} from '../../packages/agents-a365-tooling/src'; +import { + AGENTIC_USER_ID, + AGENT_ID, + BLOCK_ACTION, + BLUEPRINT_ID, + GRAPH_BASE_URL, + GRAPH_SCOPE, + PROCESS_CONTENT_URL, + PURVIEW_ENVIRONMENT_VARIABLES, + TENANT_ID, + createGraphToken, + fakeGraph, + json, + processed, + purviewConfiguration, + sentText, + waitForAbort, +} from './fixtures/purview'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const CARD_TEXT = 'Please charge my card 4111 1111 1111 1111 for the hotel.'; + +const AGENT: PurviewDlpAgentContext = { + agentId: AGENT_ID, + tenantId: TENANT_ID, + agenticUserId: AGENTIC_USER_ID, + blueprintId: BLUEPRINT_ID, + agentName: 'SampleAgent', + sessionId: 'conversation-1', + sequence: 3, +}; + +/** A host token resolver (never cached by the client) that records each request. */ +function hostTokens(token = createGraphToken(), userId?: string): { + resolve: PurviewDlpTokenResolver; + requests: Array<{ agent: PurviewDlpAgentContext; scopes: string[] }>; +} { + const requests: Array<{ agent: PurviewDlpAgentContext; scopes: string[] }> = []; + return { + requests, + resolve: async (agent, scopes) => { + requests.push({ agent, scopes }); + return { accessToken: token, ...(userId ? { userId } : {}) }; + }, + }; +} + +/** A fake Agents SDK connection that records each agentic user token request. */ +function agenticConnection(issue: () => string | Promise = () => createGraphToken()): { + connection: PurviewDlpAgenticUserConnection; + requests: string[]; +} { + const requests: string[] = []; + return { + requests, + connection: { + getAgenticUserToken: async (tenantId, agentId, agenticUserId, scopes) => { + requests.push(`${tenantId}|${agentId}|${agenticUserId}|${scopes.join(' ')}`); + return await issue(); + }, + }, + }; +} + +function create( + respond: (body: Record, init: RequestInit, url: string) => Response | Promise, + overrides: ToolingConfigurationOptions = {}, +): { client: PurviewDlpClient; calls: ReturnType['calls']; tokens: ReturnType } { + const graph = fakeGraph(respond); + const client = new PurviewDlpClient({ configProvider: purviewConfiguration(overrides), fetchImplementation: graph.fetch }); + return { client, calls: graph.calls, tokens: hostTokens() }; +} + +const allow = (): Response => processed(); +const block = (): Response => processed([BLOCK_ACTION]); + +describe('PurviewDlpClient', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + for (const name of PURVIEW_ENVIRONMENT_VARIABLES) delete process.env[name]; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('requests', () => { + it('returns null without calls when disabled', async () => { + const { client, calls, tokens } = create(block, { isPurviewDlpEnabled: () => false }); + + const result = await client.evaluate('uploadText', CARD_TEXT, AGENT, tokens.resolve); + + expect(result).toBeNull(); + expect(calls).toHaveLength(0); + expect(tokens.requests).toHaveLength(0); + }); + + it.each(['', ' ', '\n\t'])('returns null without calls for text without content (%j)', async (text) => { + const { client, calls, tokens } = create(block); + + expect(await client.evaluate('uploadText', text, AGENT, tokens.resolve)).toBeNull(); + expect(calls).toHaveLength(0); + expect(tokens.requests).toHaveLength(0); + }); + + it('sends the prompt to processContent as the agent\'s conversation message', async () => { + const { client, calls, tokens } = create(allow); + const token = createGraphToken(); + const resolve: PurviewDlpTokenResolver = async () => ({ accessToken: token }); + + const result = await client.evaluate('uploadText', 'Find flights to Paris', AGENT, resolve); + + expect(tokens.requests).toHaveLength(0); + expect(calls).toHaveLength(1); + const call = calls[0]; + expect(call.url).toBe(PROCESS_CONTENT_URL); + expect(call.method).toBe('POST'); + expect(call.authorization).toBe(`Bearer ${token}`); + expect(call.contentType).toBe('application/json'); + expect(call.redirect).toBe('error'); + expect(call.clientRequestId).toMatch(UUID); + expect(result?.correlationId).toBe(call.clientRequestId); + const request = call.body.contentToProcess; + const entry = request.contentEntries[0]; + expect(request.contentEntries).toHaveLength(1); + expect(entry).toEqual({ + '@odata.type': 'microsoft.graph.processConversationMetadata', + identifier: call.clientRequestId, + content: { '@odata.type': 'microsoft.graph.textContent', data: 'Find flights to Paris' }, + name: 'SampleAgent uploadText', + correlationId: 'conversation-1', + sequenceNumber: 3, + isTruncated: false, + createdDateTime: expect.stringMatching(/Z$/), + modifiedDateTime: entry.createdDateTime, + contentCategory: 'ai', + agents: [{ + '@odata.type': 'microsoft.graph.aiAgentInfo', + identifier: AGENT_ID, + blueprintId: BLUEPRINT_ID, + name: 'SampleAgent', + version: '1.0', + }], + }); + expect(Number.isNaN(Date.parse(entry.createdDateTime))).toBe(false); + expect(request.activityMetadata).toEqual({ activity: 'uploadText' }); + expect(request.integratedAppMetadata).toEqual({ name: 'SampleAgent', version: '1.0' }); + expect(request.protectedAppMetadata).toEqual({ + name: 'SampleAgent', + version: '1.0', + applicationLocation: { '@odata.type': 'microsoft.graph.policyLocationApplication', value: BLUEPRINT_ID }, + }); + }); + + it('sends a new client-request-id with every call', async () => { + const { client, calls, tokens } = create(allow); + + const first = await client.evaluate('uploadText', 'one', AGENT, tokens.resolve); + const second = await client.evaluate('uploadText', 'two', AGENT, tokens.resolve); + + expect(calls[0].clientRequestId).not.toBe(calls[1].clientRequestId); + expect([first?.correlationId, second?.correlationId]).toEqual(calls.map((call) => call.clientRequestId)); + }); + + it('sends the reply as downloadText', async () => { + const { client, calls, tokens } = create(allow); + + const result = await client.evaluate('downloadText', 'Here are three flights.', { ...AGENT, sequence: 9 }, tokens.resolve); + + expect(result?.activity).toBe('downloadText'); + expect(calls[0].body.contentToProcess.activityMetadata).toEqual({ activity: 'downloadText' }); + expect(calls[0].body.contentToProcess.contentEntries[0].name).toBe('SampleAgent downloadText'); + expect(calls[0].body.contentToProcess.contentEntries[0].sequenceNumber).toBe(9); + }); + + it('scopes the application location to the blueprint, then the agent, unless an application id is given', async () => { + const { client, calls, tokens } = create(allow); + const location = (index: number): string => calls[index].body.contentToProcess.protectedAppMetadata.applicationLocation.value; + + await client.evaluate('uploadText', 'one', AGENT, tokens.resolve); + await client.evaluate('uploadText', 'two', { agentId: AGENT_ID, sessionId: 's-1' }, tokens.resolve); + await client.evaluate('uploadText', 'three', { ...AGENT, applicationId: 'application-id' }, tokens.resolve); + + expect([location(0), location(1), location(2)]).toEqual([BLUEPRINT_ID, AGENT_ID, 'application-id']); + }); + + it('names an unnamed agent by its id, leaves out an unknown blueprint and defaults the version', async () => { + const { client, calls, tokens } = create(allow); + + await client.evaluate('uploadText', 'hello', { agentId: ` ${AGENT_ID} `, sessionId: 's-1', agentName: ' ' }, tokens.resolve); + + const request = calls[0].body.contentToProcess; + expect(request.contentEntries[0].name).toBe(`${AGENT_ID} uploadText`); + expect(request.contentEntries[0].agents).toEqual([ + { '@odata.type': 'microsoft.graph.aiAgentInfo', identifier: AGENT_ID, name: AGENT_ID, version: '1.0' }, + ]); + expect(request.integratedAppMetadata).toEqual({ name: AGENT_ID, version: '1.0' }); + }); + + it('sends the agent version when given', async () => { + const { client, calls, tokens } = create(allow); + + await client.evaluate('uploadText', 'hello', { ...AGENT, agentVersion: '2.3.1' }, tokens.resolve); + + const request = calls[0].body.contentToProcess; + expect(request.contentEntries[0].agents[0].version).toBe('2.3.1'); + expect(request.protectedAppMetadata.version).toBe('2.3.1'); + }); + + it('numbers each session\'s messages when the agent context has no sequence', async () => { + const { client, calls, tokens } = create(allow); + const { sequence: _sequence, ...unnumbered } = AGENT; + + await client.evaluate('uploadText', 'one', unnumbered, tokens.resolve); + await client.evaluate('downloadText', 'two', unnumbered, tokens.resolve); + await client.evaluate('uploadText', 'three', { ...unnumbered, sessionId: 'conversation-2' }, tokens.resolve); + await client.evaluate('uploadText', 'four', { ...unnumbered, sequence: -1 }, tokens.resolve); + + expect(calls.map((call) => call.body.contentToProcess.contentEntries[0].sequenceNumber)).toEqual([0, 1, 0, 2]); + }); + + it('keeps numbering a session upward after it is no longer tracked', async () => { + const { client, calls, tokens } = create(allow); + const { sequence: _sequence, ...unnumbered } = AGENT; + + await client.evaluate('uploadText', 'first', unnumbered, tokens.resolve); + for (let index = 0; index < 1000; index += 1) { + await client.evaluate('uploadText', 'other', { ...unnumbered, sessionId: `other-${index}` }, tokens.resolve); + } + await client.evaluate('uploadText', 'again', unnumbered, tokens.resolve); + + const sequences = calls.map((call) => call.body.contentToProcess.contentEntries[0].sequenceNumber); + expect(sequences[0]).toBe(0); + expect(sequences[sequences.length - 1]).toBeGreaterThan(0); + }); + + it('replaces lone surrogates in the text and names, so the request is well formed JSON', async () => { + const { client, calls, tokens } = create(allow); + + await client.evaluate('uploadText', 'card \uD800 4111 \uD83D\uDE00', { ...AGENT, agentName: 'Sample\uDC00Agent' }, tokens.resolve); + + expect(sentText(calls[0])).toBe('card \uFFFD 4111 \uD83D\uDE00'); + expect(calls[0].body.contentToProcess.integratedAppMetadata.name).toBe('Sample\uFFFDAgent'); + expect(calls[0].raw).not.toMatch(/\\ud[89a-f]/i); + }); + + it('evaluates for the user the token resolver names', async () => { + const { client, calls } = create(allow); + const tokens = hostTokens(createGraphToken(), 'user@example.test'); + + await client.evaluate('uploadText', 'hello', AGENT, tokens.resolve); + + expect(calls[0].url).toBe(`${GRAPH_BASE_URL}/users/user%40example.test/dataSecurityAndGovernance/processContent`); + }); + + it('calls the configured Graph base URL', async () => { + const { client, calls, tokens } = create(allow, { purviewDlpGraphBaseUrl: () => 'https://graph.example.test/beta/' }); + + await client.evaluate('uploadText', 'hello', AGENT, tokens.resolve); + + expect(calls[0].url).toBe('https://graph.example.test/beta/me/dataSecurityAndGovernance/processContent'); + }); + + it.each([ + ['an unknown activity', ['uploadFile', 'hello', AGENT], "activity must be 'uploadText' or 'downloadText'."], + ['text that is not a string', ['uploadText', 42, AGENT], 'text must be a string.'], + ['no agent', ['uploadText', 'hello', undefined], 'agent is required.'], + ['no agent id', ['uploadText', 'hello', { sessionId: 's-1' }], 'agent.agentId is required.'], + ['no session id', ['uploadText', 'hello', { agentId: AGENT_ID, sessionId: ' ' }], 'agent.sessionId is required.'], + ])('rejects %s', async (_case, [activity, text, agent], message) => { + const { client, calls, tokens } = create(allow); + + await expect(client.evaluate(activity as never, text as never, agent as never, tokens.resolve)).rejects.toThrow(message); + expect(calls).toHaveLength(0); + }); + + it('rejects a missing token resolver', async () => { + const { client } = create(allow); + + await expect(client.evaluate('uploadText', 'hello', AGENT, undefined as never)).rejects.toThrow('tokenResolver is required.'); + }); + }); + + describe('verdicts', () => { + it('allows content Purview does not restrict', async () => { + const { client, tokens } = create(() => processed([], { protectionScopeState: 'modified' })); + + const result = await client.evaluate('uploadText', 'Find flights to Paris', AGENT, tokens.resolve); + + expect(result).toEqual({ + allowed: true, + evaluated: true, + truncated: false, + activity: 'uploadText', + correlationId: expect.stringMatching(UUID), + decision: { blockAction: false, actionCount: 0 }, + protectionScopeState: 'modified', + httpStatus: 200, + latencyMilliseconds: expect.any(Number), + }); + }); + + it.each([ + ['uploadText', 'The request was blocked by a Microsoft Purview data loss prevention policy.'], + ['downloadText', 'The response was blocked by a Microsoft Purview data loss prevention policy.'], + ] as const)('blocks %s that a policy action restricts with block', async (activity, reason) => { + const { client, tokens } = create(block, { purviewDlpFailClosed: () => false }); + + const result = await client.evaluate(activity, CARD_TEXT, AGENT, tokens.resolve); + + expect(result).toMatchObject({ + allowed: false, + evaluated: true, + truncated: false, + decision: { blockAction: true, restrictionAction: 'block', actionCount: 1 }, + blockReason: reason, + }); + expect(result?.error).toBeUndefined(); + }); + + it('reads the restriction action in any case', async () => { + const { client, tokens } = create(() => processed([{ ...BLOCK_ACTION, restrictionAction: 'Block' }])); + + const result = await client.evaluate('uploadText', CARD_TEXT, AGENT, tokens.resolve); + + expect(result?.allowed).toBe(false); + expect(result?.decision).toEqual({ blockAction: true, restrictionAction: 'Block', actionCount: 1 }); + }); + + it('allows content with other restriction actions, counting them', async () => { + const { client, tokens } = create(() => processed([ + { '@odata.type': '#microsoft.graph.notifyUserAction', action: 'notifyUser' }, + { ...BLOCK_ACTION, restrictionAction: 'warn' }, + { ...BLOCK_ACTION, restrictionAction: 'audit' }, + ])); + + const result = await client.evaluate('uploadText', CARD_TEXT, AGENT, tokens.resolve); + + expect(result?.allowed).toBe(true); + expect(result?.decision).toEqual({ blockAction: false, restrictionAction: 'warn', actionCount: 3 }); + }); + + it('blocks when any one action blocks', async () => { + const { client, tokens } = create(() => processed([{ ...BLOCK_ACTION, restrictionAction: 'warn' }, BLOCK_ACTION])); + + const result = await client.evaluate('uploadText', CARD_TEXT, AGENT, tokens.resolve); + + expect(result?.decision).toEqual({ blockAction: true, restrictionAction: 'block', actionCount: 2 }); + }); + + it.each([ + [{ '@odata.type': '#microsoft.graph.blockAccessAction', action: 'blockAccess' }], + [{ action: 'BlockAccess', restrictionAction: 'warn' }], + [{ action: 'blockAccess', restrictionAction: 7 }], + ])('blocks an action that blocks access (%j), as Microsoft\'s Purview integrations do', async (action) => { + const { client, tokens } = create(() => processed([action]), { purviewDlpFailClosed: () => false }); + + const result = await client.evaluate('uploadText', CARD_TEXT, AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: false, evaluated: true, decision: { blockAction: true, actionCount: 1 } }); + }); + + it('follows the fail mode for an action whose type has another shape', async () => { + const { client, tokens } = create(() => processed([{ action: ['blockAccess'] }]), { purviewDlpFailClosed: () => true }); + + const result = await client.evaluate('uploadText', CARD_TEXT, AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: false, evaluated: false, error: 'response had a policy action of another shape' }); + }); + + it.each([202, 204])('allows content accepted without an inline decision (%d)', async (status) => { + const { client, tokens } = create(() => new Response(null, { status })); + + const result = await client.evaluate('uploadText', 'hello', AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: true, evaluated: true, httpStatus: status, decision: { blockAction: false, actionCount: 0 } }); + }); + + it.each([false, true])('follows the fail mode on processing errors, which Graph reports with HTTP 200 (fail closed: %s)', async (failClosed) => { + const { client, tokens } = create(() => json({ + protectionScopeState: 'notModified', + policyActions: [], + processingErrors: [{ code: 'BadRequest', message: 'Invalid request field. The provided data for Name is invalid.', errorType: 'permanent' }], + }), { purviewDlpFailClosed: () => failClosed }); + + const result = await client.evaluate('uploadText', 'hello', AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: !failClosed, evaluated: false, httpStatus: 200, error: 'processing errors: 1' }); + expect(result?.blockReason).toBe(failClosed + ? 'Data loss prevention validation is unavailable and this agent is configured to fail closed.' + : undefined); + }); + + it('keeps a block beside processing errors or actions of another shape', async () => { + const { client, tokens } = create(() => json({ policyActions: ['unexpected', BLOCK_ACTION], processingErrors: [{ code: 'Transient' }] })); + + const result = await client.evaluate('uploadText', CARD_TEXT, AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: false, evaluated: true, decision: { blockAction: true, actionCount: 2 } }); + }); + + it.each([ + ['[]', 'response was not a JSON object'], + ['null', 'response was not a JSON object'], + ['"allowed"', 'response was not a JSON object'], + ['{}', 'response had no list of policy actions'], + ['{"policyActions":{"restrictionAction":"block"}}', 'response had no list of policy actions'], + ['{"policyActions":[null]}', 'response had a policy action of another shape'], + ['{"policyActions":[{"restrictionAction":1}]}', 'response had a policy action of another shape'], + ['{"policyActions":[],"processingErrors":"none"}', 'response had processingErrors that are not a list'], + ['not json', 'non-JSON response'], + ['', 'non-JSON response'], + ])('follows the fail mode for a response of another shape (%s)', async (body, error) => { + const { client, tokens } = create(() => new Response(body, { status: 200 }), { purviewDlpFailClosed: () => true }); + + const result = await client.evaluate('uploadText', 'hello', AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: false, evaluated: false, httpStatus: 200, error }); + }); + + it('treats a null processingErrors as none', async () => { + const { client, tokens } = create(() => json({ policyActions: [], processingErrors: null })); + + expect((await client.evaluate('uploadText', 'hello', AGENT, tokens.resolve))?.evaluated).toBe(true); + }); + }); + + describe('text longer than the limit', () => { + const LIMIT = 50; + const blockMarker = (body: Record): Response => sentText({ body } as never).includes('BLOCK_ME') ? block() : allow(); + + it.each([false, true])('is cut and sent as truncated, and an allow of it follows the fail mode (fail closed: %s)', async (failClosed) => { + const { client, calls, tokens } = create(blockMarker, { + purviewDlpMaxContentCharacters: () => LIMIT, + purviewDlpFailClosed: () => failClosed, + }); + + const result = await client.evaluate('uploadText', `${'a'.repeat(LIMIT)}BLOCK_ME`, AGENT, tokens.resolve); + + expect(sentText(calls[0])).toBe('a'.repeat(LIMIT)); + expect(calls[0].body.contentToProcess.contentEntries[0].isTruncated).toBe(true); + expect(result).toMatchObject({ + allowed: !failClosed, + evaluated: true, + truncated: true, + decision: { blockAction: false }, + error: `content exceeded A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (${LIMIT}); Purview evaluated a truncated copy`, + }); + expect(result?.blockReason).toBe(failClosed + ? 'The content is too long to be fully validated by Microsoft Purview, and this agent is configured to fail closed.' + : undefined); + }); + + it('keeps Purview\'s block of truncated text', async () => { + const { client, tokens } = create(blockMarker, { purviewDlpMaxContentCharacters: () => LIMIT }); + + const result = await client.evaluate('uploadText', `BLOCK_ME${'a'.repeat(LIMIT)}`, AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: false, evaluated: true, truncated: true, decision: { blockAction: true } }); + expect(result?.error).toBeUndefined(); + }); + + it('evaluates text at the limit normally', async () => { + const { client, calls, tokens } = create(allow, { purviewDlpMaxContentCharacters: () => LIMIT, purviewDlpFailClosed: () => true }); + + const result = await client.evaluate('uploadText', 'a'.repeat(LIMIT), AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: true, evaluated: true, truncated: false }); + expect(calls[0].body.contentToProcess.contentEntries[0].isTruncated).toBe(false); + }); + + it('does not split a surrogate pair when cutting', async () => { + const { client, calls, tokens } = create(allow, { purviewDlpMaxContentCharacters: () => 5 }); + + await client.evaluate('uploadText', 'abcd\uD83D\uDE00efgh', AGENT, tokens.resolve); + + expect(sentText(calls[0])).toBe('abcd'); + }); + + it('marks a failure for truncated text as truncated, without changing it', async () => { + const { client, tokens } = create(() => json({}, 503), { purviewDlpMaxContentCharacters: () => LIMIT }); + + const result = await client.evaluate('uploadText', 'a'.repeat(LIMIT + 1), AGENT, tokens.resolve); + + expect(result).toMatchObject({ evaluated: false, truncated: true, error: 'http 503' }); + }); + }); + + describe('failures', () => { + it.each([false, true])('follows the fail mode on an HTTP error, reporting only its status (fail closed: %s)', async (failClosed) => { + const { client, tokens } = create( + () => json({ error: { code: 'Forbidden', message: `Access denied for ${CARD_TEXT}` } }, 403), + { purviewDlpFailClosed: () => failClosed }, + ); + + const result = await client.evaluate('uploadText', CARD_TEXT, AGENT, tokens.resolve); + + expect(result).toMatchObject({ allowed: !failClosed, evaluated: false, httpStatus: 403, error: 'http 403' }); + expect(JSON.stringify(result)).not.toContain('4111'); + }); + + it('reports a timeout', async () => { + const { client } = create((_body, init) => waitForAbort(init), { purviewDlpTimeoutMilliseconds: () => 100 }); + + const result = await client.evaluate('uploadText', 'hello', AGENT, hostTokens().resolve); + + expect(result).toMatchObject({ evaluated: false, error: 'request timeout' }); + }); + + it('reports the network error code of a failed request', async () => { + const failing = (): never => { + throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }); + }; + const { client } = create(failing); + + const result = await client.evaluate('uploadText', 'hello', AGENT, hostTokens().resolve); + + expect(result).toMatchObject({ evaluated: false, error: 'request failed: ECONNREFUSED' }); + }); + + it('reports only the type of another send error', async () => { + const { client } = create(() => { + throw new RangeError('sensitive detail'); + }); + + const result = await client.evaluate('uploadText', 'hello', AGENT, hostTokens().resolve); + + expect(result?.error).toBe('request failed: RangeError'); + }); + + it.each([false, true])('follows the fail mode when the response body cannot be read (fail closed: %s)', async (failClosed) => { + const unreadable = { ok: true, status: 200, text: async () => Promise.reject(new Error('reset')) } as unknown as Response; + const { client } = create(() => unreadable, { purviewDlpFailClosed: () => failClosed }); + + const result = await client.evaluate('uploadText', 'hello', AGENT, hostTokens().resolve); + + expect(result).toMatchObject({ allowed: !failClosed, evaluated: false, httpStatus: 200, error: 'response body could not be read' }); + }); + + it('follows the fail mode on an error raised while reading the response', async () => { + const broken = { get status(): number { throw new Error('broken response'); } } as unknown as Response; + const { client } = create(() => broken, { purviewDlpFailClosed: () => true }); + + const result = await client.evaluate('uploadText', 'hello', AGENT, hostTokens().resolve); + + expect(result).toMatchObject({ allowed: false, evaluated: false, error: 'request failed: Error' }); + }); + + it('rejects with the caller\'s reason when the caller cancels', async () => { + const { client } = create((_body, init) => waitForAbort(init)); + const controller = new AbortController(); + + const pending = client.evaluate('uploadText', 'hello', AGENT, hostTokens().resolve, controller.signal); + setTimeout(() => controller.abort(new Error('turn cancelled')), 20); + + await expect(pending).rejects.toThrow('turn cancelled'); + }); + + it('creates a not-evaluated result that follows the fail mode', () => { + const open = create(allow).client; + const closed = create(allow, { purviewDlpFailClosed: () => true }).client; + + expect(open.unavailable('uploadText', 'no agent identity was resolved')).toEqual({ + allowed: true, + evaluated: false, + truncated: false, + activity: 'uploadText', + correlationId: expect.stringMatching(UUID), + decision: { blockAction: false, actionCount: 0 }, + latencyMilliseconds: 0, + error: 'no agent identity was resolved', + }); + expect(closed.unavailable('downloadText', 'x', 5)).toMatchObject({ + allowed: false, + latencyMilliseconds: 5, + blockReason: 'Data loss prevention validation is unavailable and this agent is configured to fail closed.', + }); + }); + }); + + describe('authentication', () => { + it('asks a host token resolver for every evaluation, with the configured scope', async () => { + const { client, calls, tokens } = create(allow, { purviewDlpAuthenticationScope: () => 'https://graph.example.test/.default' }); + + await client.evaluate('uploadText', 'one', AGENT, tokens.resolve); + await client.evaluate('uploadText', 'two', AGENT, tokens.resolve); + + expect(tokens.requests.map((request) => request.scopes)).toEqual([['https://graph.example.test/.default'], ['https://graph.example.test/.default']]); + expect(tokens.requests[0].agent).toMatchObject({ agentId: AGENT_ID, sessionId: 'conversation-1' }); + expect(calls).toHaveLength(2); + }); + + it('gets the agentic user token from the connection and caches it per tenant, agent, user and scope', async () => { + const { client, calls } = create(allow); + const { connection, requests } = agenticConnection(); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + await client.evaluate('uploadText', 'one', AGENT, tokens); + await client.evaluate('downloadText', 'two', { ...AGENT, tenantId: TENANT_ID.toUpperCase() }, tokens); + await client.evaluate('uploadText', 'three', { ...AGENT, agenticUserId: 'another-agentic-user' }, tokens); + + expect(requests).toEqual([ + `${TENANT_ID}|${AGENT_ID}|${AGENTIC_USER_ID}|${GRAPH_SCOPE}`, + `${TENANT_ID}|${AGENT_ID}|another-agentic-user|${GRAPH_SCOPE}`, + ]); + expect(calls).toHaveLength(3); + expect(calls.every((call) => call.url === PROCESS_CONTENT_URL)).toBe(true); + }); + + it('shares one token acquisition between concurrent evaluations', async () => { + const { client, calls } = create(allow); + const { connection, requests } = agenticConnection(); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + await Promise.all([1, 2, 3].map((n) => client.evaluate('uploadText', `message ${n}`, AGENT, tokens))); + + expect(requests).toHaveLength(1); + expect(calls).toHaveLength(3); + }); + + it('refreshes a token that is about to expire', async () => { + const { client } = create(allow); + const { connection, requests } = agenticConnection(() => createGraphToken(60)); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + await client.evaluate('uploadText', 'one', AGENT, tokens); + await client.evaluate('uploadText', 'two', AGENT, tokens); + + expect(requests).toHaveLength(2); + }); + + it('uses the cached token during an early refresh and keeps it when the refresh fails', async () => { + const { client, calls } = create(allow); + const token = createGraphToken(60); + let attempts = 0; + const { connection } = agenticConnection(() => { + attempts += 1; + if (attempts > 1) throw new Error('refresh failed'); + return token; + }); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + const results = []; + for (const text of ['one', 'two', 'three']) { + results.push(await client.evaluate('uploadText', text, AGENT, tokens)); + } + + expect(results.every((result) => result?.evaluated && result.allowed)).toBe(true); + expect(calls.map((call) => call.authorization)).toEqual(Array(3).fill(`Bearer ${token}`)); + expect(attempts).toBeGreaterThanOrEqual(2); + }); + + it('does not wait for a slow early refresh', async () => { + const { client, calls } = create(allow, { purviewDlpTimeoutMilliseconds: () => 200 }); + let attempts = 0; + const { connection } = agenticConnection(() => { + attempts += 1; + return attempts === 1 ? createGraphToken(60) : new Promise(() => undefined); + }); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + await client.evaluate('uploadText', 'one', AGENT, tokens); + const started = Date.now(); + + const result = await client.evaluate('uploadText', 'two', AGENT, tokens); + + expect(result?.evaluated).toBe(true); + expect(Date.now() - started).toBeLessThan(150); + expect(calls).toHaveLength(2); + }); + + it.each(['[]', 'null', '"claims"', '{"exp":"soon"}', 'not json'])( + 'uses a token whose payload is not an object with a numeric exp (%s), without caching it', + async (payload) => { + const { client, calls } = create(allow); + const { connection, requests } = agenticConnection(() => `e30.${Buffer.from(payload).toString('base64url')}.signature`); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + const results = [ + await client.evaluate('uploadText', 'one', AGENT, tokens), + await client.evaluate('uploadText', 'two', AGENT, tokens), + ]; + + expect(results.map((result) => result?.evaluated)).toEqual([true, true]); + expect(calls).toHaveLength(2); + expect(requests).toHaveLength(2); + }, + ); + + it.each([false, true])('follows the fail mode when no token can be acquired, reporting only the error type (fail closed: %s)', async (failClosed) => { + const { client, calls } = create(allow, { purviewDlpFailClosed: () => failClosed }); + const failing: PurviewDlpTokenResolver = () => { + throw new Error('AADSTS50000: secret-looking detail eyJhbGciOi'); + }; + + const result = await client.evaluate('uploadText', 'hello', AGENT, failing); + + expect(result).toMatchObject({ evaluated: false, allowed: !failClosed, error: 'token unavailable: Error' }); + expect(calls).toHaveLength(0); + }); + + it('reports why the agentic user token cannot be requested', async () => { + const { client, calls } = create(allow); + const { connection, requests } = agenticConnection(); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + const result = await client.evaluate('uploadText', 'hello', { ...AGENT, agenticUserId: undefined }, tokens); + + expect(result?.error).toBe('token unavailable: TypeError: agent.agenticUserId is required for the agentic user token.'); + expect(requests).toHaveLength(0); + expect(calls).toHaveLength(0); + }); + + it.each([ + [async () => null, 'token unavailable: Error: The Purview token resolver returned no token.'], + [async () => ({ accessToken: ' ' }), 'token unavailable: Error: The Purview token resolver returned no token.'], + [async () => 'bare-token' as never, 'token unavailable: Error: The Purview token resolver returned no token.'], + [async () => ({ accessToken: createGraphToken(), userId: ' ' }), + 'token unavailable: Error: The Purview token resolver returned a userId that is not a non-empty string.'], + [async () => ({ accessToken: createGraphToken(-60) }), 'token unavailable: Error: The Purview token resolver returned an expired token.'], + ] as Array<[PurviewDlpTokenResolver, string]>)('rejects what a token resolver returns that cannot be used (%#)', async (resolver, error) => { + const { client, calls } = create(allow); + + const result = await client.evaluate('uploadText', 'hello', AGENT, resolver); + + expect(result?.error).toBe(error); + expect(calls).toHaveLength(0); + }); + + it('stops waiting for a token resolver that does not return within the timeout', async () => { + const { client, calls } = create(allow, { purviewDlpTimeoutMilliseconds: () => 100 }); + const started = Date.now(); + + const result = await client.evaluate('uploadText', 'hello', AGENT, () => new Promise(() => undefined)); + + expect(result).toMatchObject({ evaluated: false, error: 'token unavailable: timeout' }); + expect(Date.now() - started).toBeLessThan(2000); + expect(calls).toHaveLength(0); + }); + + it('applies one deadline to the token acquisition and the request', async () => { + const { client } = create((_body, init) => waitForAbort(init), { purviewDlpTimeoutMilliseconds: () => 1000 }); + const slowToken: PurviewDlpTokenResolver = () => new Promise((resolve) => setTimeout(() => resolve({ accessToken: createGraphToken() }), 700)); + const started = Date.now(); + + const result = await client.evaluate('uploadText', 'hello', AGENT, slowToken); + + // Separate timeouts would take about 1700 ms. + expect(result?.error).toBe('request timeout'); + expect(Date.now() - started).toBeLessThan(1500); + }); + + it('does not cache a failed token acquisition', async () => { + const { client, calls } = create(allow); + let attempts = 0; + const { connection } = agenticConnection(() => { + attempts += 1; + if (attempts === 1) throw new Error('transient'); + return createGraphToken(); + }); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + const first = await client.evaluate('uploadText', 'one', AGENT, tokens); + const second = await client.evaluate('uploadText', 'two', AGENT, tokens); + + expect(first?.error).toBe('token unavailable: Error'); + expect(second?.evaluated).toBe(true); + expect(attempts).toBe(2); + expect(calls).toHaveLength(1); + }); + + it('keeps the shared token acquisition when a waiting caller cancels', async () => { + const { client, calls } = create(allow); + let attempts = 0; + let release: (token: string) => void = () => undefined; + const { connection } = agenticConnection(() => { + attempts += 1; + return new Promise((resolve) => { + release = resolve; + }); + }); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + const controller = new AbortController(); + + const cancelled = client.evaluate('uploadText', 'one', AGENT, tokens, controller.signal); + const waiting = client.evaluate('uploadText', 'two', AGENT, tokens); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(new Error('turn cancelled')); + await expect(cancelled).rejects.toThrow('turn cancelled'); + release(createGraphToken()); + const result = await waiting; + + expect(result?.evaluated).toBe(true); + expect(attempts).toBe(1); + expect(calls).toHaveLength(1); + }); + + it('drops the oldest cached token beyond 100 agentic users', async () => { + const { client } = create(allow); + const { connection, requests } = agenticConnection(); + const tokens = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + for (let index = 0; index <= 100; index += 1) { + await client.evaluate('uploadText', 'hello', { ...AGENT, agenticUserId: `user-${index}` }, tokens); + } + await client.evaluate('uploadText', 'hello', { ...AGENT, agenticUserId: 'user-100' }, tokens); + expect(requests).toHaveLength(101); + + await client.evaluate('uploadText', 'hello', { ...AGENT, agenticUserId: 'user-0' }, tokens); + expect(requests).toHaveLength(102); + }); + }); + + describe('configuration', () => { + it.each([ + 'http://graph.example.test/v1.0', + 'graph.example.test/v1.0', + 'https://user:secret@graph.example.test/v1.0', + 'https://graph.example.test/v1.0?api-version=1', + 'https://graph.example.test/v1.0#fragment', + ])('requires an absolute https Graph base URL without credentials, query or fragment (%s)', (baseUrl) => { + expect(() => new PurviewDlpClient({ configProvider: purviewConfiguration({ purviewDlpGraphBaseUrl: () => baseUrl }) })) + .toThrow('A365_PURVIEW_DLP_GRAPH_BASE_URL must be an absolute https URL without credentials, query or fragment.'); + }); + + it('can be created without valid settings while disabled', () => { + process.env.A365_PURVIEW_DLP_FAIL_MODE = 'typo'; + + expect(() => new PurviewDlpClient({ + configProvider: purviewConfiguration({ isPurviewDlpEnabled: () => false, purviewDlpGraphBaseUrl: () => 'http://plain' }), + })).not.toThrow(); + }); + + it.each([ + ['A365_PURVIEW_DLP_FAIL_MODE', 'typo', "A365_PURVIEW_DLP_FAIL_MODE must be 'open' or 'closed'."], + ['A365_PURVIEW_DLP_RESPONSE_MODE', 'block', "A365_PURVIEW_DLP_RESPONSE_MODE must be 'audit' or 'enforce'."], + ['ENABLE_A365_PURVIEW_DLP', 'maybe', 'ENABLE_A365_PURVIEW_DLP must be true or false (or 1/0, yes/no, on/off).'], + ])('rejects %s=%s at construction', (name, value, message) => { + process.env.ENABLE_A365_PURVIEW_DLP = 'true'; + process.env[name] = value; + + expect(() => new PurviewDlpClient()).toThrow(message); + }); + + it('checks the Graph base URL again when the configuration changes', async () => { + let baseUrl = GRAPH_BASE_URL; + const graph = fakeGraph(allow); + const client = new PurviewDlpClient({ + configProvider: purviewConfiguration({ purviewDlpGraphBaseUrl: () => baseUrl }), + fetchImplementation: graph.fetch, + }); + baseUrl = 'http://graph.example.test/v1.0'; + + await expect(client.evaluate('uploadText', 'hello', AGENT, hostTokens().resolve)) + .rejects.toThrow('A365_PURVIEW_DLP_GRAPH_BASE_URL must be an absolute https URL'); + expect(graph.calls).toHaveLength(0); + }); + + it('reads its settings from the environment by default', async () => { + process.env.ENABLE_A365_PURVIEW_DLP = 'true'; + process.env.A365_PURVIEW_DLP_GRAPH_BASE_URL = 'https://graph.example.test/beta'; + const graph = fakeGraph(allow); + const client = new PurviewDlpClient({ fetchImplementation: graph.fetch }); + + await client.evaluate('uploadText', 'hello', AGENT, hostTokens().resolve); + + expect(graph.calls[0].url).toBe('https://graph.example.test/beta/me/dataSecurityAndGovernance/processContent'); + }); + }); +}); + +describe('PurviewDlpTokenResolvers', () => { + const signal = new AbortController().signal; + + it('returns the agentic user token for /me', async () => { + const { connection, requests } = agenticConnection(() => 'agentic-user-token'); + const resolve = PurviewDlpTokenResolvers.fromAgenticUser(connection); + + const token = await resolve(AGENT, [GRAPH_SCOPE], signal); + + expect(token).toEqual({ accessToken: 'agentic-user-token' }); + expect(requests).toEqual([`${TENANT_ID}|${AGENT_ID}|${AGENTIC_USER_ID}|${GRAPH_SCOPE}`]); + }); + + it('fails when the connection returns no token, or the identity is incomplete', async () => { + const resolve = PurviewDlpTokenResolvers.fromAgenticUser(agenticConnection(() => '').connection); + + await expect(resolve(AGENT, [GRAPH_SCOPE], signal)).rejects.toThrow('The agent connection returned no agentic user token.'); + await expect(resolve({ ...AGENT, tenantId: ' ' }, [GRAPH_SCOPE], signal)).rejects.toThrow('agent.tenantId is required'); + await expect(resolve({ ...AGENT, agentId: '' }, [GRAPH_SCOPE], signal)).rejects.toThrow('agent.agentId is required'); + }); + + it('stops when cancelled', async () => { + const { connection, requests } = agenticConnection(); + const controller = new AbortController(); + controller.abort(new Error('cancelled')); + + await expect(PurviewDlpTokenResolvers.fromAgenticUser(connection)(AGENT, [GRAPH_SCOPE], controller.signal)).rejects.toThrow('cancelled'); + expect(requests).toHaveLength(0); + }); + + it('requires a connection with getAgenticUserToken', () => { + expect(() => PurviewDlpTokenResolvers.fromAgenticUser(undefined as never)).toThrow('connection must provide getAgenticUserToken.'); + expect(() => PurviewDlpTokenResolvers.fromAgenticUser({} as never)).toThrow('connection must provide getAgenticUserToken.'); + }); + + it('returns the host\'s token, for /me or for the named user', async () => { + const requests: unknown[][] = []; + const getToken = async (...args: unknown[]): Promise => { + requests.push(args); + return 'host-token'; + }; + + expect(await PurviewDlpTokenResolvers.fromAccessTokenProvider(getToken)(AGENT, [GRAPH_SCOPE], signal)) + .toEqual({ accessToken: 'host-token' }); + expect(await PurviewDlpTokenResolvers.fromAccessTokenProvider(getToken, ' user-object-id ')(AGENT, [GRAPH_SCOPE], signal)) + .toEqual({ accessToken: 'host-token', userId: 'user-object-id' }); + expect(requests[0]).toEqual([[GRAPH_SCOPE], signal, AGENT]); + }); + + it('returns nothing when the host has no token', async () => { + expect(await PurviewDlpTokenResolvers.fromAccessTokenProvider(() => '')(AGENT, [GRAPH_SCOPE], signal)).toBeNull(); + expect(await PurviewDlpTokenResolvers.fromAccessTokenProvider(() => undefined)(AGENT, [GRAPH_SCOPE], signal)).toBeNull(); + }); + + it('requires a token provider and a non-empty user id when one is given', () => { + expect(() => PurviewDlpTokenResolvers.fromAccessTokenProvider(undefined as never)).toThrow('getToken is required.'); + expect(() => PurviewDlpTokenResolvers.fromAccessTokenProvider(() => 'token', ' ')).toThrow('userId must be a non-empty string when set.'); + }); +}); From ed248e0d25b71bf40fb068726b74f6e5844c4bfd Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala <12496535+DheerajPannala@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:23:32 +0100 Subject: [PATCH 2/6] Bound structured-content reading and report only SDK error messages - Keep an error's message in results and verdict warnings only when the SDK raised it (PurviewDlpClient.describeError); errors from a host's configuration provider, call resolver or token resolver are reported by type, as they may carry a token or a response body. - Read structured content lazily within bounds (limit + 1 characters, four values per character of the limit, 32 levels), and evaluate content read only in part as truncated through the new evaluate(..., { truncated }) option, so a block of what was read stands and an allow follows the fail mode. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc2218e-29c5-4011-8ea5-e36163d58d4c --- CHANGELOG.md | 3 +- .../README.md | 6 +- .../docs/design.md | 14 +- .../src/A365PurviewInterceptor.ts | 160 +++++++++++------- packages/agents-a365-tooling/docs/design.md | 10 +- .../src/configuration/ToolingConfiguration.ts | 11 +- .../src/purview/PurviewDlpClient.ts | 53 ++++-- .../src/purview/contracts.ts | 10 ++ .../A365PurviewInterceptor.test.ts | 69 +++++++- tests/tooling/purview-dlp-client.test.ts | 59 +++++++ 10 files changed, 296 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00830ea5..5747565d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,7 +117,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`downloadText`: sent in the background and allowed at once in the default `audit` mode, awaited in `enforce` mode), with a callback for each evaluation; `addA365Purview` registers it next to `A365DefenderInterceptor` on one emitter. Contexts that cannot be verified follow the fail mode. - `createProtectionEmitter` accepts `purviewConfigProvider`, and its default interceptor timeout + Structured content is read lazily within bounds, and content read only in part is evaluated as + truncated. `createProtectionEmitter` accepts `purviewConfigProvider`, and its default interceptor timeout covers the Purview timeout when Purview DLP is enabled; Defender-only emitters are unchanged. ## [1.0.0] - 2026-04-30 diff --git a/packages/agents-a365-tooling-extensions-agenthooks/README.md b/packages/agents-a365-tooling-extensions-agenthooks/README.md index 02e156aa..e272a055 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/README.md +++ b/packages/agents-a365-tooling-extensions-agenthooks/README.md @@ -231,8 +231,10 @@ application and records each interaction for audit (DSPM for AI, Activity explor Other points are allowed without a call: Purview DLP evaluates prompts and replies, not tool calls. Content without text is allowed without a call. A string is sent as it is; structured content (for example content parts) is sent as -its string and number values, in order, one per line, read only as far as `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` -(structured content that is still blank there follows the fail mode, as the rest was not read). +its string and number values, in order, one per line. Structured content is read within bounds (at most the limit +plus one character of text, four values per character of `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS`, and 32 levels of +nesting); content read only in part is sent as truncated, so Purview's block stands and its allow follows the fail +mode. ### Response mode diff --git a/packages/agents-a365-tooling-extensions-agenthooks/docs/design.md b/packages/agents-a365-tooling-extensions-agenthooks/docs/design.md index 95bb6511..5488c790 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/docs/design.md +++ b/packages/agents-a365-tooling-extensions-agenthooks/docs/design.md @@ -126,16 +126,18 @@ other points, Purview DLP disabled, or content without text ──► allow, no - `input` is evaluated as `uploadText` and `output` as `downloadText`; other points, every point while `ENABLE_A365_PURVIEW_DLP` is off, and content without text are allowed without calling `resolveCall` or Purview. The text of a string is the string; of structured content, its string and number values in order, one per line, - each object read once and reading stopped once the text is longer than the limit (the client then marks it - truncated). Structured content that is still blank when reading stops is not empty, as the rest was never read: it - follows the fail mode without a call. + each object read once. Structured content is read lazily and within bounds: at most the limit plus one + character of text is kept, at most four values per character of the limit are read (empty values count), and + nesting deeper than 32 levels is left unread. Content read only in part is passed to the client as + `truncated`, so Purview's block of what was read stands and its allow follows the fail mode (as does content + that is still blank where reading stopped). - `resolveCall` returns the agent identity and token resolver (`A365PurviewCall`). The interceptor sets the agent context's `sessionId` and `sequence` from the context (`session.id`, `sequence`), so Purview's conversation lines up with the interception records, and `agentName` from `agent.name` when the call sets none. `null` or `undefined` means no agent identity: Purview is not called and the context follows the fail mode - (`no agent identity was resolved`). An exception from `resolveCall` does too, reporting only its type, as a host's - error may carry anything; an exception from `evaluate` (an invalid argument or configuration) keeps the SDK's - message. + (`no agent identity was resolved`). An exception from `resolveCall`, the configuration or the client does too; + it is described by `PurviewDlpClient.describeError`, which keeps the message only of errors the SDK raised + itself, as a host's (for example from a configuration provider) may carry a token or a response body. - **Replies in `audit` mode** (`A365_PURVIEW_DLP_RESPONSE_MODE`, the default): Purview DLP policies for custom AI apps cannot restrict replies, so the evaluation starts in the background and the interceptor returns `allow` at once, whatever the fail mode. The background evaluation is bounded by the client's own timeout and never by the diff --git a/packages/agents-a365-tooling-extensions-agenthooks/src/A365PurviewInterceptor.ts b/packages/agents-a365-tooling-extensions-agenthooks/src/A365PurviewInterceptor.ts index 2ca5d928..c233d14e 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/src/A365PurviewInterceptor.ts +++ b/packages/agents-a365-tooling-extensions-agenthooks/src/A365PurviewInterceptor.ts @@ -11,8 +11,10 @@ import { } from '@microsoft/agents-a365-tooling'; const NO_IDENTITY_ERROR = 'no agent identity was resolved'; -const MAX_ERROR_CHARACTERS = 200; -const ERROR_TYPE = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; +/** The most values read from structured content, per character of the limit; every value counts, empty ones too. */ +const VALUES_PER_CHARACTER = 4; +/** The deepest nesting read from structured content; deeper values are left unread. */ +const MAX_DEPTH = 32; const BLOCK_MESSAGES: Readonly> = { uploadText: 'The request was blocked by a Microsoft Purview data loss prevention policy.', downloadText: 'The response was blocked by a Microsoft Purview data loss prevention policy.', @@ -121,7 +123,7 @@ export class A365PurviewInterceptor implements Interceptor { result = await this.evaluate(context, activity); } catch (error) { // An invalid configuration is never a verdict: it follows the fail mode. - result = this.client.unavailable(activity, describeError(error, true)); + result = this.client.unavailable(activity, PurviewDlpClient.describeError(error)); } if (!result) { @@ -179,34 +181,28 @@ export class A365PurviewInterceptor implements Interceptor { /** * Evaluates the context's text. Never rejects for a missing identity or an invalid context: those become - * not-evaluated results, which follow the fail mode. + * not-evaluated results, which follow the fail mode. Errors are described by `PurviewDlpClient.describeError`: + * only the SDK's own keep their message, as a host's may carry a token or a response body. */ private async evaluate(context: AgentContext, activity: PurviewDlpActivity): Promise { let limit: number; try { limit = this.client.configuration.purviewDlpMaxContentCharacters; } catch (error) { - return this.client.unavailable(activity, describeError(error, true)); + return this.client.unavailable(activity, PurviewDlpClient.describeError(error)); } - let text: string; - let complete: boolean; + let content: { text: string; complete: boolean }; try { - ({ text, complete } = contentText(contentOf(context, activity), limit)); + content = contentText(contentOf(context, activity), limit); } catch (error) { - // The content is the host's, so only the type of what it raised is reported. - return this.client.unavailable(activity, `content could not be read: ${describeError(error, false)}`); + return this.client.unavailable(activity, `content could not be read: ${PurviewDlpClient.describeError(error)}`); } - if (!text.trim()) { - // Nothing to evaluate, so no identity is needed. Content whose first characters are blank but that goes - // on past the limit is not empty: the rest was never read, so it follows the fail mode. - return complete - ? null - : this.client.unavailable( - activity, - `content exceeded A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (${limit}) before any text; Purview was not called`, - ); + // Nothing to evaluate, so no identity is needed. Content read only in part is never empty: the client + // decides it as truncated. + if (content.complete && !content.text.trim()) { + return null; } // Read before anything is awaited: a reply is evaluated after the interceptor has returned. @@ -215,8 +211,7 @@ export class A365PurviewInterceptor implements Interceptor { try { call = await this.resolveCall(context); } catch (error) { - // The host's error may carry anything, so only its type is reported. - return this.client.unavailable(activity, `${NO_IDENTITY_ERROR}: ${describeError(error, false)}`); + return this.client.unavailable(activity, `${NO_IDENTITY_ERROR}: ${PurviewDlpClient.describeError(error)}`); } // Without an agent identity Purview cannot be called: the content is unverified, not allowed. @@ -225,10 +220,16 @@ export class A365PurviewInterceptor implements Interceptor { } try { - return await this.client.evaluate(activity, text, agentFor(call.agent, turn), call.tokenResolver); + return await this.client.evaluate( + activity, + content.text, + agentFor(call.agent, turn), + call.tokenResolver, + undefined, + { truncated: !content.complete }, + ); } catch (error) { - // The client throws only for invalid arguments or configuration, with the SDK's own messages. - return this.client.unavailable(activity, describeError(error, true)); + return this.client.unavailable(activity, PurviewDlpClient.describeError(error)); } } @@ -315,10 +316,11 @@ function contentOf(context: AgentContext, activity: PurviewDlpActivity): unknown } /** - * The text of a message's content: a string as it is; structured content (for example content parts) as - * its string and number values in order, one per line, each object read once. Reading stops once the text - * is longer than `limit`, as the client sends at most that much and marks the content as truncated; - * `complete` tells whether everything was read. + * The text of a message's content: a string as it is; structured content (for example content parts) as its + * string and number values in order, one per line, each object read once. Structured content is read lazily + * and within bounds: at most `limit + 1` characters of text are kept (so the client still sees text that is + * too long), at most four values per character of the limit are read (empty values included), and nesting + * deeper than 32 levels is left unread. `complete` is false when anything was left unread or cut. */ function contentText(content: unknown, limit: number): { text: string; complete: boolean } { if (typeof content === 'string') { @@ -328,32 +330,82 @@ function contentText(content: unknown, limit: number): { text: string; complete: const parts: string[] = []; // The length of the joined text, separators included. let length = 0; - const pending: unknown[] = [content]; + let remaining = VALUES_PER_CHARACTER * limit; + let complete = true; const seen = new Set(); - while (pending.length > 0 && length <= limit) { - const node = pending.pop(); - if (typeof node === 'object' && node !== null) { - if (!seen.has(node)) { - seen.add(node); - const children: unknown[] = Array.isArray(node) ? node : Object.values(node); - for (let index = children.length - 1; index >= 0; index -= 1) { - pending.push(children[index]); - } + const stack: Array> = []; + const result = (): { text: string; complete: boolean } => ({ text: parts.join('\n'), complete }); + let next: { value: unknown } | undefined = { value: content }; + for (;;) { + if (!next) { + const top = stack[stack.length - 1]; + if (!top) { + return result(); + } + + const step = top.next(); + if (step.done) { + stack.pop(); + continue; + } + + next = { value: step.value }; + } + + const value = next.value; + next = undefined; + if (remaining <= 0 || length > limit) { + complete = false; + return result(); + } + + remaining -= 1; + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + continue; + } + + if (stack.length >= MAX_DEPTH) { + complete = false; + continue; } + seen.add(value); + stack.push(valuesOf(value)); continue; } - const text = typeof node === 'string' - ? node - : (typeof node === 'number' && Number.isFinite(node)) || typeof node === 'bigint' ? String(node) : ''; + const text = typeof value === 'string' + ? value + : (typeof value === 'number' && Number.isFinite(value)) || typeof value === 'bigint' ? String(value) : ''; if (text) { - length += (parts.length > 0 ? 1 : 0) + text.length; - parts.push(text); + const separator = parts.length > 0 ? 1 : 0; + const kept = text.slice(0, Math.max(0, limit + 1 - length - separator)); + parts.push(kept); + length += separator + kept.length; + if (kept.length < text.length) { + complete = false; + return result(); + } } } +} - return { text: parts.join('\n'), complete: pending.length === 0 }; +/** The values of an array or object, read one at a time. */ +function* valuesOf(node: object): Generator { + if (Array.isArray(node)) { + for (let index = 0; index < node.length; index += 1) { + yield node[index]; + } + + return; + } + + for (const key in node) { + if (Object.prototype.hasOwnProperty.call(node, key)) { + yield (node as Record)[key]; + } + } } function isObject(value: unknown): value is Record { @@ -365,23 +417,3 @@ function isThenable(value: unknown): value is PromiseLike { && value !== null && typeof (value as { then?: unknown }).then === 'function'; } - -/** - * The exception type, for example `TypeError`, and with `withMessage` its message: only for the SDK's own - * errors, as a host's may carry a token or a response body. - */ -function describeError(error: unknown, withMessage: boolean): string { - if (typeof error !== 'object' || error === null) { - return 'unknown error'; - } - - const name = (error as { name?: unknown }).name; - const type = typeof name === 'string' && ERROR_TYPE.test(name) ? name : 'Error'; - if (!withMessage) { - return type; - } - - const message = (error as { message?: unknown }).message; - const description = `${type}: ${typeof message === 'string' ? message : ''}`.replace(/\s+/g, ' ').trim(); - return description.length > MAX_ERROR_CHARACTERS ? `${description.slice(0, MAX_ERROR_CHARACTERS)}...` : description; -} diff --git a/packages/agents-a365-tooling/docs/design.md b/packages/agents-a365-tooling/docs/design.md index 6cb62ca8..ded1c3d3 100644 --- a/packages/agents-a365-tooling/docs/design.md +++ b/packages/agents-a365-tooling/docs/design.md @@ -282,13 +282,15 @@ if (result && !result.allowed) { /* block: result.blockReason */ } - **Failures**: non-empty `processingErrors` (no block), a response without a well-formed `policyActions` list, a non-2xx status, a timeout, a transport error or an unparsable body returns `evaluated: false`, with `allowed` following `purviewDlpFailClosed`. `error` names the failure, or the exception type only (`http 403`, - `request timeout`, `token unavailable: TypeError`), never a response body or token; the SDK's own argument errors - keep their message. An invalid argument (for example no `sessionId`) throws; `unavailable(...)` builds the - matching not-evaluated result. + `request timeout`, `token unavailable: TypeError`), never a response body or token; errors the SDK raises itself + (an invalid argument or setting) keep their message. `PurviewDlpClient.describeError(error)` applies the same + rule for callers, such as the agent-hooks interceptor. An invalid argument (for example no `sessionId`) throws; + `unavailable(...)` builds the matching not-evaluated result. - **Truncation**: text longer than `purviewDlpMaxContentCharacters` (default 100000) is cut (never splitting a surrogate pair) and sent with `isTruncated: true`. A block still stands, but an allow does not cover the rest: the result is `truncated: true` and `allowed` follows the fail mode, so text padded past the limit cannot be - authorized unseen. + authorized unseen. A caller that read only the first part of the content passes `{ truncated: true }` as the + sixth argument, with the same effect; blank text marked so is not sent and follows the fail mode. - **Authentication**: a `PurviewDlpTokenResolver` is `(agent, scopes, signal) => { accessToken, userId? }`; without `userId` the client calls `/me`, with it `/users/{userId}`. `PurviewDlpTokenResolvers.fromAgenticUser(connection)` returns the agentic user's delegated Microsoft Graph token diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts index 2727a63c..70601a41 100644 --- a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts @@ -5,6 +5,7 @@ import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; import { ToolingConfigurationOptions } from './ToolingConfigurationOptions'; import { MCPServerConfig } from '../contracts'; import type { PurviewDlpResponseMode } from '../purview/contracts'; +import { sdkError } from '../purview/internal'; // Constants for tooling-specific settings const MCP_PLATFORM_PROD_BASE_URL = 'https://agent365.svc.cloud.microsoft'; @@ -290,7 +291,7 @@ export class ToolingConfiguration extends RuntimeConfiguration { return true; } - throw new Error('ENABLE_A365_PURVIEW_DLP must be true or false (or 1/0, yes/no, on/off).'); + throw sdkError(new Error('ENABLE_A365_PURVIEW_DLP must be true or false (or 1/0, yes/no, on/off).')); } /** @@ -334,7 +335,7 @@ export class ToolingConfiguration extends RuntimeConfiguration { ); if (!Number.isInteger(timeout) || timeout <= 0 || timeout > MAX_PURVIEW_DLP_TIMEOUT_MILLISECONDS) { - throw new Error(`purviewDlpTimeoutMilliseconds must be a positive integer of at most ${MAX_PURVIEW_DLP_TIMEOUT_MILLISECONDS}.`); + throw sdkError(new Error(`purviewDlpTimeoutMilliseconds must be a positive integer of at most ${MAX_PURVIEW_DLP_TIMEOUT_MILLISECONDS}.`)); } return timeout; } @@ -358,7 +359,7 @@ export class ToolingConfiguration extends RuntimeConfiguration { return true; } - throw new Error("A365_PURVIEW_DLP_FAIL_MODE must be 'open' or 'closed'."); + throw sdkError(new Error("A365_PURVIEW_DLP_FAIL_MODE must be 'open' or 'closed'.")); } /** @@ -375,7 +376,7 @@ export class ToolingConfiguration extends RuntimeConfiguration { ); if (!Number.isInteger(maximum) || maximum <= 0) { - throw new Error('purviewDlpMaxContentCharacters must be a positive integer.'); + throw sdkError(new Error('purviewDlpMaxContentCharacters must be a positive integer.')); } return maximum; } @@ -392,7 +393,7 @@ export class ToolingConfiguration extends RuntimeConfiguration { ? override : process.env.A365_PURVIEW_DLP_RESPONSE_MODE?.trim().toLowerCase() || 'audit'; if (!PURVIEW_DLP_RESPONSE_MODES.has(mode)) { - throw new Error("A365_PURVIEW_DLP_RESPONSE_MODE must be 'audit' or 'enforce'."); + throw sdkError(new Error("A365_PURVIEW_DLP_RESPONSE_MODE must be 'audit' or 'enforce'.")); } return mode as PurviewDlpResponseMode; } diff --git a/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts b/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts index d8f1459c..276a5c68 100644 --- a/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts +++ b/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts @@ -9,6 +9,7 @@ import { PurviewDlpActivity, PurviewDlpAgentContext, PurviewDlpDecision, + PurviewDlpEvaluateOptions, PurviewDlpEvaluationResult, PurviewDlpTokenResolver, } from './contracts'; @@ -44,6 +45,8 @@ const TOKEN_REFRESH_SKEW_MILLISECONDS = 5 * 60 * 1000; const FAIL_CLOSED_REASON = 'Data loss prevention validation is unavailable and this agent is configured to fail closed.'; const TRUNCATED_FAIL_CLOSED_REASON = 'The content is too long to be fully validated by Microsoft Purview, and this agent is configured to fail closed.'; +const PARTLY_READ_ERROR = 'the content was only partly read; Purview evaluated a truncated copy'; +const BLANK_PART_ERROR = 'the content was only partly read, and the part read has no text; Purview was not called'; const BLOCK_REASONS: Readonly> = { uploadText: 'The request was blocked by a Microsoft Purview data loss prevention policy.', downloadText: 'The response was blocked by a Microsoft Purview data loss prevention policy.', @@ -134,19 +137,33 @@ export class PurviewDlpClient { return typeof activity === 'string' && ACTIVITIES.has(activity); } + /** + * Describes an exception for a result's `error`: its type (for example `TypeError`), with the message only + * when the SDK raised it (an invalid argument or setting). Another exception, for example from a host's + * configuration provider or token resolver, may carry a token or a response body, so only its type is kept. + * + * @param error The exception. + * @returns The description, on one line. + */ + public static describeError(error: unknown): string { + return describeError(error); + } + /** * Evaluates text with Purview DLP. One deadline (the configured timeout) covers the token acquisition * and the request. * * Text longer than `purviewDlpMaxContentCharacters` is cut and sent with `isTruncated: true`, so Purview * sees only its beginning. A block still stands, but an allow does not cover the rest: the result is - * marked `truncated`, and `allowed` follows the fail mode. + * marked `truncated`, and `allowed` follows the fail mode. The same holds for text the caller marks as + * only the first part of the content (`options.truncated`). * * @param activity `uploadText` for a prompt, `downloadText` for a reply. * @param text The text to evaluate. * @param agent The agent identity and conversation (`sessionId` is required). * @param tokenResolver Resolves the Microsoft Graph token, and the user to evaluate for. * @param signal Cancels the evaluation; the returned promise then rejects with its reason. + * @param options Whether the text is only the first part of the content. * @returns The result, or null without a call when Purview DLP is disabled or the text is empty. When * no verdict is obtained (token, transport, timeout, HTTP or processing failure) the result is not * evaluated and follows the configured fail mode. @@ -159,6 +176,7 @@ export class PurviewDlpClient { agent: PurviewDlpAgentContext, tokenResolver: PurviewDlpTokenResolver, signal?: AbortSignal, + options: PurviewDlpEvaluateOptions = {}, ): Promise { const configuration = this.configuration; if (!configuration.isPurviewDlpEnabled) { @@ -166,30 +184,37 @@ export class PurviewDlpClient { } if (!PurviewDlpClient.isActivity(activity)) { - throw new TypeError("activity must be 'uploadText' or 'downloadText'."); + throw sdkError(new TypeError("activity must be 'uploadText' or 'downloadText'.")); } if (typeof text !== 'string') { - throw new TypeError('text must be a string.'); + throw sdkError(new TypeError('text must be a string.')); } PurviewDlpClient.requireArguments(agent, tokenResolver); + const partial = options?.truncated === true; if (text.trim().length === 0) { - return null; + // Blank text has nothing to evaluate, unless it is only the first part of the content: the rest was not + // read, so it is unverified. + return partial ? { ...this.unavailable(activity, BLANK_PART_ERROR), truncated: true } : null; } const identity = this.identity(agent); const baseUrl = PurviewDlpClient.graphBaseUrl(configuration); signal?.throwIfAborted(); const maxCharacters = configuration.purviewDlpMaxContentCharacters; - const truncated = text.length > maxCharacters; - const data = wellFormed(truncated ? cut(text, maxCharacters) : text); + const tooLong = text.length > maxCharacters; + const truncated = tooLong || partial; + const data = wellFormed(tooLong ? cut(text, maxCharacters) : text); // One id per call: the client-request-id, and the content entry's identifier in Purview. const requestId = this.idFactory(); const started = this.now(); const deadline = createDeadline(configuration.purviewDlpTimeoutMilliseconds, signal); + const truncation = tooLong + ? `content exceeded A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (${maxCharacters}); Purview evaluated a truncated copy` + : PARTLY_READ_ERROR; const complete = (result: PurviewDlpEvaluationResult): PurviewDlpEvaluationResult => - truncated ? PurviewDlpClient.ofTruncatedContent(result, maxCharacters, configuration) : result; + truncated ? PurviewDlpClient.ofTruncatedContent(result, truncation, configuration) : result; try { let token: PurviewDlpAccessToken; try { @@ -229,12 +254,12 @@ export class PurviewDlpClient { private identity(agent: PurviewDlpAgentContext): Identity { const agentId = nonEmpty(agent.agentId); if (!agentId) { - throw new TypeError('agent.agentId is required.'); + throw sdkError(new TypeError('agent.agentId is required.')); } const sessionId = nonEmpty(agent.sessionId); if (!sessionId) { - throw new TypeError('agent.sessionId is required.'); + throw sdkError(new TypeError('agent.sessionId is required.')); } const blueprintId = nonEmpty(agent.blueprintId); @@ -299,7 +324,7 @@ export class PurviewDlpClient { */ private static ofTruncatedContent( result: PurviewDlpEvaluationResult, - maxCharacters: number, + error: string, configuration: ToolingConfiguration, ): PurviewDlpEvaluationResult { if (!result.evaluated || result.decision.blockAction) { @@ -311,7 +336,7 @@ export class PurviewDlpClient { ...result, truncated: true, allowed: !failClosed, - error: `content exceeded A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (${maxCharacters}); Purview evaluated a truncated copy`, + error, ...(failClosed ? { blockReason: TRUNCATED_FAIL_CLOSED_REASON } : {}), }; } @@ -613,7 +638,7 @@ export class PurviewDlpClient { private static graphBaseUrl(configuration: ToolingConfiguration): string { const url = parseHttpsUrl(configuration.purviewDlpGraphBaseUrl); if (!url || url.username || url.password || url.search || url.hash) { - throw new Error('A365_PURVIEW_DLP_GRAPH_BASE_URL must be an absolute https URL without credentials, query or fragment.'); + throw sdkError(new Error('A365_PURVIEW_DLP_GRAPH_BASE_URL must be an absolute https URL without credentials, query or fragment.')); } return trimTrailingSlashes(`${url.origin}${url.pathname}`); @@ -621,11 +646,11 @@ export class PurviewDlpClient { private static requireArguments(agent: PurviewDlpAgentContext, tokenResolver: PurviewDlpTokenResolver): void { if (!isObject(agent)) { - throw new TypeError('agent is required.'); + throw sdkError(new TypeError('agent is required.')); } if (typeof tokenResolver !== 'function') { - throw new TypeError('tokenResolver is required.'); + throw sdkError(new TypeError('tokenResolver is required.')); } } } diff --git a/packages/agents-a365-tooling/src/purview/contracts.ts b/packages/agents-a365-tooling/src/purview/contracts.ts index 649e82d7..5f38d1c1 100644 --- a/packages/agents-a365-tooling/src/purview/contracts.ts +++ b/packages/agents-a365-tooling/src/purview/contracts.ts @@ -93,6 +93,16 @@ export type PurviewDlpAccessTokenProvider = ( agent: PurviewDlpAgentContext, ) => Promise | string | null | undefined; +/** Options for `PurviewDlpClient.evaluate`. */ +export interface PurviewDlpEvaluateOptions { + /** + * Whether the text is only the first part of the content (for example of a message too large to read + * whole). It is sent with `isTruncated: true`, Purview's allow does not cover the rest and follows the fail + * mode, and blank text is not evaluated but follows the fail mode too. + */ + truncated?: boolean; +} + /** What Purview decided, read from the `policyActions` of the `processContent` response. */ export interface PurviewDlpDecision { /** diff --git a/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts b/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts index c376f199..1861f98b 100644 --- a/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts +++ b/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts @@ -489,17 +489,19 @@ describe('A365PurviewInterceptor under the agent-hooks emitter', () => { it.each([false, true])( 'follows the fail mode for structured content that is blank up to the limit and goes on (fail closed: %s)', async (failClosed) => { - const { emitter, calls, resolvedCount } = harness(() => processed([BLOCK_ACTION]), { + const { emitter, calls } = harness(() => processed([BLOCK_ACTION]), { configuration: { purviewDlpMaxContentCharacters: () => 10, purviewDlpFailClosed: () => failClosed }, }); const unverified = { reason: 'purview:unverified', - message: 'content exceeded A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (10) before any text; Purview was not called', + message: 'the content was only partly read, and the part read has no text; Purview was not called', }; const records = [ await emitter.emitUnchecked(builder('conversation-27').input([' '.repeat(11), CARD])), await emitter.emitUnchecked(builder('conversation-27').input([{ text: '\n'.repeat(11), type: 'text' }, { text: CARD, type: 'text' }])), + // More values than the reading bound (four per character of the limit), all of them empty. + await emitter.emitUnchecked(builder('conversation-27').input([...Array.from({ length: 100 }, () => ''), CARD])), ]; for (const record of records) { @@ -507,10 +509,71 @@ describe('A365PurviewInterceptor under the agent-hooks emitter', () => { expect(record.verdict.warnings).toEqual([unverified]); } expect(calls).toHaveLength(0); - expect(resolvedCount()).toBe(0); }, ); + it('sends what was read before the reading bound as truncated', async () => { + const { emitter, calls, evaluations } = harness(blockCard, { + configuration: { purviewDlpMaxContentCharacters: () => 10, purviewDlpFailClosed: () => true }, + }); + + const record = await emitter.emitUnchecked(builder('conversation-29').input(['hello', ...Array.from({ length: 100 }, () => null), CARD])); + await settle(); + + expect(proceeds(record)).toBe(false); + expect(record.verdict.reason).toBe('runtime_error:purview_unverified'); + expect(sentText(calls[0])).toBe('hello'); + expect(calls[0].body.contentToProcess.contentEntries[0].isTruncated).toBe(true); + expect(evaluations[0]).toMatchObject({ + evaluated: true, + truncated: true, + error: 'the content was only partly read; Purview evaluated a truncated copy', + }); + }); + + it('leaves nesting deeper than 32 levels unread, reads the rest, and keeps Purview\'s block', async () => { + const { emitter, calls } = harness(blockCard); + let nested: JsonValue = 'deep text'; + for (let level = 0; level < 40; level += 1) nested = [nested]; + + const record = await emitter.emitUnchecked(builder('conversation-30').input([nested, CARD])); + + expect(proceeds(record)).toBe(false); + expect(record.verdict.reason).toBe('purview:block'); + expect(sentText(calls[0])).toBe(CARD); + expect(calls[0].body.contentToProcess.contentEntries[0].isTruncated).toBe(true); + }); + + it('keeps no more than the limit of one large part of structured content', async () => { + const { emitter, calls } = harness(blockCard, { configuration: { purviewDlpMaxContentCharacters: () => 10 } }); + + const record = await emitter.emitUnchecked(builder('conversation-31').input([{ text: 'x'.repeat(100000) }])); + + expect(proceeds(record)).toBe(true); + expect(record.verdict.warnings?.[0]?.reason).toBe('purview:unverified'); + expect(sentText(calls[0])).toBe('x'.repeat(10)); + expect(calls[0].body.contentToProcess.contentEntries[0].isTruncated).toBe(true); + }); + + it('reports only the type of an error raised by the host\'s configuration', async () => { + let failing = false; + const { emitter, calls } = harness(processed, { + configuration: { + purviewDlpMaxContentCharacters: () => { + if (failing) throw new Error('settings service returned token secret-value'); + return 100; + }, + }, + }); + failing = true; + + const record = await emitter.emitUnchecked(builder('conversation-32').input('hello')); + + expect(proceeds(record)).toBe(true); + expect(record.verdict.warnings).toEqual([{ reason: 'purview:unverified', message: 'Error' }]); + expect(calls).toHaveLength(0); + }); + it('evaluates a message that is blank up to the limit and goes on, as truncated', async () => { const { emitter, calls } = harness(blockCard, { configuration: { purviewDlpMaxContentCharacters: () => 10, purviewDlpFailClosed: () => true }, diff --git a/tests/tooling/purview-dlp-client.test.ts b/tests/tooling/purview-dlp-client.test.ts index 01985205..b6f859d3 100644 --- a/tests/tooling/purview-dlp-client.test.ts +++ b/tests/tooling/purview-dlp-client.test.ts @@ -8,6 +8,7 @@ import { PurviewDlpClient, PurviewDlpTokenResolver, PurviewDlpTokenResolvers, + ToolingConfiguration, ToolingConfigurationOptions, } from '../../packages/agents-a365-tooling/src'; import { @@ -500,6 +501,64 @@ describe('PurviewDlpClient', () => { expect(result).toMatchObject({ evaluated: false, truncated: true, error: 'http 503' }); }); + + it.each([false, true])('treats text the caller marks as only part of the content as truncated (fail closed: %s)', async (failClosed) => { + const { client, calls, tokens } = create(blockMarker, { purviewDlpFailClosed: () => failClosed }); + + const allowed = await client.evaluate('uploadText', 'first part', AGENT, tokens.resolve, undefined, { truncated: true }); + const blocked = await client.evaluate('uploadText', 'BLOCK_ME first part', AGENT, tokens.resolve, undefined, { truncated: true }); + + expect(calls.map((call) => call.body.contentToProcess.contentEntries[0].isTruncated)).toEqual([true, true]); + expect(sentText(calls[0])).toBe('first part'); + expect(allowed).toMatchObject({ + allowed: !failClosed, + evaluated: true, + truncated: true, + error: 'the content was only partly read; Purview evaluated a truncated copy', + }); + expect(blocked).toMatchObject({ allowed: false, evaluated: true, truncated: true, decision: { blockAction: true } }); + }); + + it('follows the fail mode without a call for blank text that is only part of the content', async () => { + const { client, calls, tokens } = create(blockMarker, { purviewDlpFailClosed: () => true }); + + const result = await client.evaluate('uploadText', ' ', AGENT, tokens.resolve, undefined, { truncated: true }); + + expect(result).toMatchObject({ + allowed: false, + evaluated: false, + truncated: true, + error: 'the content was only partly read, and the part read has no text; Purview was not called', + }); + expect(calls).toHaveLength(0); + expect(tokens.requests).toHaveLength(0); + }); + }); + + describe('describeError', () => { + it('keeps the message of the SDK\'s own errors only', async () => { + const { client, tokens } = create(allow); + const sdkError = await client.evaluate('uploadText', 'hello', { sessionId: 's-1' } as never, tokens.resolve).catch((error) => error); + const hostError = new Error('settings service returned token secret-value'); + + expect(PurviewDlpClient.describeError(sdkError)).toBe('TypeError: agent.agentId is required.'); + expect(PurviewDlpClient.describeError(hostError)).toBe('Error'); + expect(PurviewDlpClient.describeError(Object.assign(new Error('x'), { name: 'GraphServiceError' }))).toBe('GraphServiceError'); + expect(PurviewDlpClient.describeError(Object.assign(new Error('x'), { name: 'secret value with spaces' }))).toBe('Error'); + expect(PurviewDlpClient.describeError('secret-value')).toBe('unknown error'); + }); + + it('keeps the message of the SDK\'s configuration errors', () => { + process.env.A365_PURVIEW_DLP_RESPONSE_MODE = 'block'; + let error: unknown; + try { + void new ToolingConfiguration().purviewDlpResponseMode; + } catch (caught) { + error = caught; + } + + expect(PurviewDlpClient.describeError(error)).toBe("Error: A365_PURVIEW_DLP_RESPONSE_MODE must be 'audit' or 'enforce'."); + }); }); describe('failures', () => { From e3ece4609f39ed1f20f93e0b56c98b3573dc1673 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala <12496535+DheerajPannala@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:33:10 +0100 Subject: [PATCH 3/6] Reject Purview number settings that are not whole numbers A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS and A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS were read with parseInt, so a value such as 10s became 10 ms and timed out every evaluation, failing open. They now must be whole numbers; anything else throws. Also documents that a result is truncated when the caller marks the text as only part of the content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc2218e-29c5-4011-8ea5-e36163d58d4c --- CHANGELOG.md | 3 +- CLAUDE.md | 4 +-- .../README.md | 4 +-- packages/agents-a365-tooling/docs/design.md | 8 ++--- .../src/configuration/ToolingConfiguration.ts | 18 +++++------ .../src/purview/contracts.ts | 6 ++-- .../PurviewDlpConfiguration.test.ts | 30 +++++++++++++++++-- 7 files changed, 50 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5747565d..4edd1ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,7 +108,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `A365_PURVIEW_DLP_FAIL_MODE`, `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` (default 10000), `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` and `A365_PURVIEW_DLP_RESPONSE_MODE` (`audit` or `enforce`, default `audit`), or the matching `ToolingConfiguration` overrides; unknown values of - the enable flag and the modes are rejected. No new dependency. + the enable flag and the modes, and number settings that are not whole numbers, are rejected. No new + dependency. ### Added (`@microsoft/agents-a365-tooling-extensions-agenthooks`, Purview) diff --git a/CLAUDE.md b/CLAUDE.md index 14b619ff..ac5c6630 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,8 +224,8 @@ The keyword "Kairo" is legacy and should not appear in any code. Flag and remove | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | Microsoft Graph base URL of `processContent` | https URL (default: `https://graph.microsoft.com/v1.0`) | | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | Override the Microsoft Graph token scope | Scope string (default: `https://graph.microsoft.com/.default`) | | `A365_PURVIEW_DLP_FAIL_MODE` | Behavior when no Purview verdict is obtained | `open` (default), `closed`; other values are rejected | -| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | Timeout of each Purview evaluation | Number (default: 10000, at most 2147481647) | -| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Max characters of text sent to Purview; longer text is sent truncated and follows the fail mode unless blocked | Number (default: 100000) | +| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | Timeout of each Purview evaluation | Whole number (default: 10000, at most 2147481647); other values are rejected | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Max characters of text sent to Purview; longer text is sent truncated and follows the fail mode unless blocked | Whole number (default: 100000); other values are rejected | | `A365_PURVIEW_DLP_RESPONSE_MODE` | How replies are handled | `audit` (default, never blocked), `enforce`; other values are rejected | | `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override observability auth scopes | Space-separated scope strings | | `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable Agent365 exporter | `true`, `false` (default) | diff --git a/packages/agents-a365-tooling-extensions-agenthooks/README.md b/packages/agents-a365-tooling-extensions-agenthooks/README.md index e272a055..f8a95f8b 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/README.md +++ b/packages/agents-a365-tooling-extensions-agenthooks/README.md @@ -399,8 +399,8 @@ The interceptor sets the content entry's `correlationId` and `sequenceNumber` fr | `ENABLE_A365_PURVIEW_DLP` | `true` (or 1, yes, on) to call Purview; `false` (or 0, no, off) or unset leaves it off, and the interceptor allows everything without a call. Any other value fails at startup | | `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` (the default) sends replies without waiting and never blocks them; `enforce` blocks them like prompts. Any other value is rejected | | `A365_PURVIEW_DLP_FAIL_MODE` | `closed` blocks when no verdict is obtained; `open` (the default) allows. Any other value is rejected | -| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | deadline of each evaluation, token acquisition included (default 10000, at most 2147481647) | -| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | the longest text sent (default 100000); longer text is sent truncated and, unless Purview blocks it, follows the fail mode | +| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | deadline of each evaluation, token acquisition included (default 10000, at most 2147481647). A value that is not a whole number (for example `10s`) is rejected | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | the longest text sent (default 100000); longer text is sent truncated and, unless Purview blocks it, follows the fail mode. A value that is not a whole number is rejected | | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | the Microsoft Graph base URL (default `https://graph.microsoft.com/v1.0`; `https` only) | | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | overrides the Microsoft Graph token scope (default `https://graph.microsoft.com/.default`) | diff --git a/packages/agents-a365-tooling/docs/design.md b/packages/agents-a365-tooling/docs/design.md index ded1c3d3..b989215d 100644 --- a/packages/agents-a365-tooling/docs/design.md +++ b/packages/agents-a365-tooling/docs/design.md @@ -425,8 +425,8 @@ const customConfig = new ToolingConfiguration({ | `purviewDlpGraphBaseUrl` | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | `https://graph.microsoft.com/v1.0` | Microsoft Graph base URL of `processContent`; the client requires an absolute `https` URL | | `purviewDlpAuthenticationScope` | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | `https://graph.microsoft.com/.default` | OAuth scope of the Microsoft Graph token | | `purviewDlpFailClosed` | `A365_PURVIEW_DLP_FAIL_MODE` | `false` (open) | `closed` blocks when no verdict is obtained; values other than `open` and `closed` throw | -| `purviewDlpTimeoutMilliseconds` | `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | `10000` | Timeout of each evaluation, token acquisition included; at most 2147481647 | -| `purviewDlpMaxContentCharacters` | `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | `100000` | Maximum characters of text sent; longer text is sent truncated and, unless Purview blocks it, follows the fail mode | +| `purviewDlpTimeoutMilliseconds` | `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | `10000` | Timeout of each evaluation, token acquisition included; at most 2147481647; the variable must be a whole number | +| `purviewDlpMaxContentCharacters` | `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | `100000` | Maximum characters of text sent; longer text is sent truncated and, unless Purview blocks it, follows the fail mode; the variable must be a whole number | | `purviewDlpResponseMode` | `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` | `audit` or `enforce` handling of replies; other values throw | | `clusterCategory` | `CLUSTER_CATEGORY` | `prod` | (Inherited) Environment cluster | | `isDevelopmentEnvironment` | - | Derived | (Inherited) true if cluster is 'local' or 'dev' | @@ -475,8 +475,8 @@ src/ | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | Microsoft Graph base URL of `processContent` (absolute `https`) | `https://graph.microsoft.com/v1.0` | | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | OAuth scope of the Microsoft Graph token | `https://graph.microsoft.com/.default` | | `A365_PURVIEW_DLP_FAIL_MODE` | `closed` blocks when no verdict is obtained; values other than `open` and `closed` are rejected | `open` | -| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | Timeout of each evaluation (at most 2147481647) | `10000` | -| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Maximum characters of text sent to Purview; longer text is sent truncated and, unless Purview blocks it, follows the fail mode | `100000` | +| `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | Timeout of each evaluation (at most 2147481647; values that are not whole numbers are rejected) | `10000` | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Maximum characters of text sent to Purview; longer text is sent truncated and, unless Purview blocks it, follows the fail mode (values that are not whole numbers are rejected) | `100000` | | `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` sends replies without waiting and never blocks them; `enforce` blocks them like prompts; other values are rejected | `audit` | ## Error Handling diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts index 70601a41..de1cc0b1 100644 --- a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts @@ -97,7 +97,7 @@ function wholeNumber(name: string): number | undefined { } if (!/^\d+$/.test(value)) { - throw new Error(`${name} must be a whole number.`); + throw sdkError(new Error(`${name} must be a whole number.`)); } return Number(value); @@ -325,14 +325,14 @@ export class ToolingConfiguration extends RuntimeConfiguration { /** * Deadline in milliseconds of each Purview evaluation, token acquisition included (default 10000). * At most 2147481647: Node fires a longer timer after 1 ms, which would fail every evaluation. + * `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` must be a whole number: a value such as `10s` throws instead of + * becoming 10 ms, which would time out every evaluation. */ get purviewDlpTimeoutMilliseconds(): number { const override = this.toolingOverrides.purviewDlpTimeoutMilliseconds?.(); const timeout = override - ?? RuntimeConfiguration.parseEnvInt( - process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS, - DEFAULT_PURVIEW_DLP_TIMEOUT_MILLISECONDS, - ); + ?? wholeNumber('A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS') + ?? DEFAULT_PURVIEW_DLP_TIMEOUT_MILLISECONDS; if (!Number.isInteger(timeout) || timeout <= 0 || timeout > MAX_PURVIEW_DLP_TIMEOUT_MILLISECONDS) { throw sdkError(new Error(`purviewDlpTimeoutMilliseconds must be a positive integer of at most ${MAX_PURVIEW_DLP_TIMEOUT_MILLISECONDS}.`)); @@ -365,15 +365,13 @@ export class ToolingConfiguration extends RuntimeConfiguration { /** * Maximum characters of the text sent to Purview (default 100000). Longer text is cut and sent with * `isTruncated: true`; Purview's block of it stands, but its allow does not cover the rest, so the - * content then follows the fail mode. + * content then follows the fail mode. `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` must be a whole number. */ get purviewDlpMaxContentCharacters(): number { const override = this.toolingOverrides.purviewDlpMaxContentCharacters?.(); const maximum = override - ?? RuntimeConfiguration.parseEnvInt( - process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS, - DEFAULT_PURVIEW_DLP_MAX_CONTENT_CHARACTERS, - ); + ?? wholeNumber('A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS') + ?? DEFAULT_PURVIEW_DLP_MAX_CONTENT_CHARACTERS; if (!Number.isInteger(maximum) || maximum <= 0) { throw sdkError(new Error('purviewDlpMaxContentCharacters must be a positive integer.')); diff --git a/packages/agents-a365-tooling/src/purview/contracts.ts b/packages/agents-a365-tooling/src/purview/contracts.ts index 5f38d1c1..fa95572a 100644 --- a/packages/agents-a365-tooling/src/purview/contracts.ts +++ b/packages/agents-a365-tooling/src/purview/contracts.ts @@ -130,8 +130,10 @@ export interface PurviewDlpEvaluationResult { /** Whether Purview returned a verdict. */ evaluated: boolean; /** - * Whether the text was longer than `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` and was sent cut. A block - * stands; an allow does not cover the rest, so `allowed` then follows the fail mode and `error` says why. + * Whether Purview saw only part of the content, so a block stands but an allow does not cover the rest: + * `allowed` then follows the fail mode and `error` says why. That is the case when the text was longer than + * `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` and was sent cut, and when the caller marked the text as only the + * first part of the content (`PurviewDlpEvaluateOptions.truncated`), even if it was sent whole. */ truncated: boolean; /** The Purview activity that was evaluated. */ diff --git a/tests/tooling/configuration/PurviewDlpConfiguration.test.ts b/tests/tooling/configuration/PurviewDlpConfiguration.test.ts index 264123c7..df27e825 100644 --- a/tests/tooling/configuration/PurviewDlpConfiguration.test.ts +++ b/tests/tooling/configuration/PurviewDlpConfiguration.test.ts @@ -86,14 +86,16 @@ describe('Purview DLP tooling configuration', () => { expect(configuration.purviewDlpResponseMode).toBe('audit'); }); - it('falls back to the defaults for blank overrides and non-numeric values, and rejects non-positive ones', () => { - process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = 'soon'; + it('falls back to the defaults for blank values, and rejects non-positive ones', () => { + process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = ' '; + process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = ''; const configuration = new ToolingConfiguration({ purviewDlpGraphBaseUrl: () => ' ', purviewDlpAuthenticationScope: () => ' ', }); expect(configuration.purviewDlpTimeoutMilliseconds).toBe(10000); + expect(configuration.purviewDlpMaxContentCharacters).toBe(100000); expect(configuration.purviewDlpGraphBaseUrl).toBe(DEFAULT_PURVIEW_DLP_GRAPH_BASE_URL); expect(configuration.purviewDlpAuthenticationScope).toBe(DEFAULT_PURVIEW_DLP_AUTHENTICATION_SCOPE); expect(() => new ToolingConfiguration({ purviewDlpTimeoutMilliseconds: () => 0 }).purviewDlpTimeoutMilliseconds) @@ -102,6 +104,30 @@ describe('Purview DLP tooling configuration', () => { .toThrow('purviewDlpMaxContentCharacters must be a positive integer.'); expect(() => new ToolingConfiguration({ purviewDlpMaxContentCharacters: () => 1.5 }).purviewDlpMaxContentCharacters) .toThrow('purviewDlpMaxContentCharacters must be a positive integer.'); + + process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = '0'; + expect(() => new ToolingConfiguration().purviewDlpTimeoutMilliseconds) + .toThrow('purviewDlpTimeoutMilliseconds must be a positive integer of at most 2147481647.'); + }); + + it.each(['soon', '10s', '10seconds', '1.5', '-5', '1e3', '+5', '0x10'])( + 'rejects a number setting that is not a whole number (%s), rather than reading its leading digits', + (value) => { + process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = value; + process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = value; + const configuration = new ToolingConfiguration(); + + expect(() => configuration.purviewDlpTimeoutMilliseconds).toThrow('A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS must be a whole number.'); + expect(() => configuration.purviewDlpMaxContentCharacters).toThrow('A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS must be a whole number.'); + }, + ); + + it('reads whole numbers with surrounding spaces or leading zeros', () => { + process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = ' 2500 '; + process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = '0100'; + + expect(new ToolingConfiguration().purviewDlpTimeoutMilliseconds).toBe(2500); + expect(new ToolingConfiguration().purviewDlpMaxContentCharacters).toBe(100); }); it('rejects a timeout beyond the timer range, which would fire after 1 ms', () => { From be9daf2e572f0896814e9aea9ee49981fdd1a335 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala <12496535+DheerajPannala@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:48:23 +0100 Subject: [PATCH 4/6] Read a 202 processContent response that has a body A 202 with a body is now read like a 200, so a block action in it stands; before, any 202 body was discarded. A 202 without a body, like a 204, stays an evaluated allow: the content was accepted without an inline decision. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc2218e-29c5-4011-8ea5-e36163d58d4c --- packages/agents-a365-tooling/docs/design.md | 4 +++- .../src/purview/PurviewDlpClient.ts | 11 +++++++++-- tests/tooling/purview-dlp-client.test.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/agents-a365-tooling/docs/design.md b/packages/agents-a365-tooling/docs/design.md index b989215d..72092346 100644 --- a/packages/agents-a365-tooling/docs/design.md +++ b/packages/agents-a365-tooling/docs/design.md @@ -278,7 +278,9 @@ if (result && !result.allowed) { /* block: result.blockReason */ } - **Verdict**: any `policyActions[]` entry whose `restrictionAction` is `block` or whose `action` is `blockAccess` (any case; the rule of Microsoft's own Purview integrations) blocks the content, even beside processing errors or malformed actions. Other actions (for example `warn` or `audit`) allow it and are counted in - `decision.actionCount`. `202` and `204` are an evaluated allow. Every node read is shape-checked. + `decision.actionCount`. `204`, and `202` without a body (accepted with no inline decision, as for an offline + evaluation), are an evaluated allow; a `202` with a body is read like a `200`, so a block in it stands. Every node + read is shape-checked. - **Failures**: non-empty `processingErrors` (no block), a response without a well-formed `policyActions` list, a non-2xx status, a timeout, a transport error or an unparsable body returns `evaluated: false`, with `allowed` following `purviewDlpFailClosed`. `error` names the failure, or the exception type only (`http 403`, diff --git a/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts b/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts index 276a5c68..88dbac88 100644 --- a/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts +++ b/packages/agents-a365-tooling/src/purview/PurviewDlpClient.ts @@ -382,8 +382,8 @@ export class PurviewDlpClient { } status = response.status; - // Accepted without an inline decision: Purview processes the content and applies no restriction. - if (status === 202 || status === 204) { + // No content: Purview processed the content and applies no inline restriction. + if (status === 204) { discard(response); return this.evaluated(activity, requestId, { blockAction: false, actionCount: 0 }, undefined, status, started); } @@ -405,6 +405,13 @@ export class PurviewDlpClient { return fail(signal.aborted ? 'request timeout' : 'response body could not be read', status); } + // Accepted without a body: the content is processed without an inline decision (as for an offline + // evaluation), so there is no restriction to apply. A 202 with a body is read like a 200, so a block in it + // stands. + if (status === 202 && !text.trim()) { + return this.evaluated(activity, requestId, { blockAction: false, actionCount: 0 }, undefined, status, started); + } + let payload: unknown; try { payload = JSON.parse(text); diff --git a/tests/tooling/purview-dlp-client.test.ts b/tests/tooling/purview-dlp-client.test.ts index b6f859d3..8eaa3f3c 100644 --- a/tests/tooling/purview-dlp-client.test.ts +++ b/tests/tooling/purview-dlp-client.test.ts @@ -393,6 +393,20 @@ describe('PurviewDlpClient', () => { expect(result).toMatchObject({ allowed: true, evaluated: true, httpStatus: status, decision: { blockAction: false, actionCount: 0 } }); }); + it('reads a 202 that has a body like a 200, so a block in it stands', async () => { + const blocking = create(() => json({ policyActions: [BLOCK_ACTION], processingErrors: [] }, 202)); + const clean = create(() => json({ protectionScopeState: 'notModified', policyActions: [], processingErrors: [] }, 202)); + const garbled = create(() => new Response('accepted', { status: 202 }), { purviewDlpFailClosed: () => true }); + + const blocked = await blocking.client.evaluate('uploadText', CARD_TEXT, AGENT, blocking.tokens.resolve); + const allowed = await clean.client.evaluate('uploadText', 'hello', AGENT, clean.tokens.resolve); + const unverified = await garbled.client.evaluate('uploadText', 'hello', AGENT, garbled.tokens.resolve); + + expect(blocked).toMatchObject({ allowed: false, evaluated: true, httpStatus: 202, decision: { blockAction: true } }); + expect(allowed).toMatchObject({ allowed: true, evaluated: true, httpStatus: 202, protectionScopeState: 'notModified' }); + expect(unverified).toMatchObject({ allowed: false, evaluated: false, httpStatus: 202, error: 'non-JSON response' }); + }); + it.each([false, true])('follows the fail mode on processing errors, which Graph reports with HTTP 200 (fail closed: %s)', async (failClosed) => { const { client, tokens } = create(() => json({ protectionScopeState: 'notModified', From 95fa6ff7ec3b8aab362c2481ba83eaba2cd90b11 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala <12496535+DheerajPannala@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:13:26 +0100 Subject: [PATCH 5/6] Document the Defender number settings' whole-number rule and caps A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS and A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS reject values that are not whole numbers, and the content limit is at most 2147483647, since #310; note it in CLAUDE.md and the tooling design document, next to the matching Purview rows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc2218e-29c5-4011-8ea5-e36163d58d4c --- CLAUDE.md | 4 ++-- packages/agents-a365-tooling/docs/design.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac5c6630..7d80062b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,9 +217,9 @@ The keyword "Kairo" is legacy and should not appear in any code. Flag and remove | `ENABLE_A365_DEFENDER_RTP` | Enable Defender real-time protection (`DefenderRtpClient`) | `true`, `false` (default); also 1/0, yes/no, on/off; other values are rejected | | `A365_DEFENDER_RTP_ENDPOINT` | Defender prevention endpoint (required when enabled) | URL string | | `A365_DEFENDER_RTP_FAIL_MODE` | Behavior when no Defender verdict is obtained | `open` (default), `closed`; other values are rejected | -| `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | Timeout of each Defender evaluation | Number (default: 10000, at most 2147481647) | +| `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | Timeout of each Defender evaluation | Whole number (default: 10000, at most 2147481647); other values are rejected | | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | Override the Defender API token scope | Scope string | -| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | Max characters of each content string sent to Defender; the request carries at most four times as much content | Number (default: 20000) | +| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | Max characters of each content string sent to Defender; the request carries at most four times as much content | Whole number (default: 20000, at most 2147483647); other values are rejected | | `ENABLE_A365_PURVIEW_DLP` | Enable Purview data loss prevention (`PurviewDlpClient`) | `true`, `false` (default); also 1/0, yes/no, on/off; other values are rejected | | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | Microsoft Graph base URL of `processContent` | https URL (default: `https://graph.microsoft.com/v1.0`) | | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | Override the Microsoft Graph token scope | Scope string (default: `https://graph.microsoft.com/.default`) | diff --git a/packages/agents-a365-tooling/docs/design.md b/packages/agents-a365-tooling/docs/design.md index 72092346..7320247d 100644 --- a/packages/agents-a365-tooling/docs/design.md +++ b/packages/agents-a365-tooling/docs/design.md @@ -420,9 +420,9 @@ const customConfig = new ToolingConfiguration({ | `isDefenderRtpEnabled` | `ENABLE_A365_DEFENDER_RTP` | `false` | Enables Defender RTP (`DefenderRtpClient`); accepts true/false, 1/0, yes/no or on/off, and any other value throws | | `defenderRtpEndpoint` | `A365_DEFENDER_RTP_ENDPOINT` | None (required when enabled) | Defender prevention endpoint | | `defenderRtpFailClosed` | `A365_DEFENDER_RTP_FAIL_MODE` | `false` (open) | `closed` blocks when no verdict is obtained; values other than `open` and `closed` throw | -| `defenderRtpTimeoutMilliseconds` | `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | `10000` | Timeout of each evaluation; at most 2147481647, as Node fires a longer timer after 1 ms | +| `defenderRtpTimeoutMilliseconds` | `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | `10000` | Timeout of each evaluation; at most 2147481647, as Node fires a longer timer after 1 ms; the variable must be a whole number | | `defenderRtpAuthenticationScope` | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | Defender API scope | OAuth scope of the Defender token | -| `defenderRtpMaxContentCharacters` | `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | `20000` | Maximum characters of each content string; the request carries at most four times as much content | +| `defenderRtpMaxContentCharacters` | `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | `20000` | Maximum characters of each content string; the request carries at most four times as much content; at most 2147483647; the variable must be a whole number | | `isPurviewDlpEnabled` | `ENABLE_A365_PURVIEW_DLP` | `false` | Enables Purview DLP (`PurviewDlpClient`); accepts true/false, 1/0, yes/no or on/off, and any other value throws | | `purviewDlpGraphBaseUrl` | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | `https://graph.microsoft.com/v1.0` | Microsoft Graph base URL of `processContent`; the client requires an absolute `https` URL | | `purviewDlpAuthenticationScope` | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | `https://graph.microsoft.com/.default` | OAuth scope of the Microsoft Graph token | @@ -470,9 +470,9 @@ src/ | `ENABLE_A365_DEFENDER_RTP` | Enables Defender RTP (true/false, 1/0, yes/no or on/off; other values are rejected) | `false` | | `A365_DEFENDER_RTP_ENDPOINT` | Defender prevention endpoint (`https:///v1/protection/evaluate`) | None | | `A365_DEFENDER_RTP_FAIL_MODE` | `closed` blocks when no verdict is obtained; values other than `open` and `closed` are rejected | `open` | -| `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | Timeout of each evaluation (at most 2147481647) | `10000` | +| `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | Timeout of each evaluation (at most 2147481647; values that are not whole numbers are rejected) | `10000` | | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | OAuth scope of the Defender token | Defender API scope | -| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | Maximum characters of each content string; the request carries at most four times as much content | `20000` | +| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | Maximum characters of each content string; the request carries at most four times as much content (at most 2147483647; values that are not whole numbers are rejected) | `20000` | | `ENABLE_A365_PURVIEW_DLP` | Enables Purview DLP (true/false, 1/0, yes/no or on/off; other values are rejected) | `false` | | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | Microsoft Graph base URL of `processContent` (absolute `https`) | `https://graph.microsoft.com/v1.0` | | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | OAuth scope of the Microsoft Graph token | `https://graph.microsoft.com/.default` | From 53da59f12328829464056389179a3301c40f36a8 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala <12496535+DheerajPannala@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:47:13 +0100 Subject: [PATCH 6/6] Cap the Purview content limit at 2147483647 A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (and its override) must be at most 2147483647, as for Defender (#310) and in the .NET and Python SDKs. A larger value, such as 308 nines, is finite but overflowed the agent-hooks interceptor's reading budget to Infinity, so structured content was read without a bound; it now throws, and the interceptor follows the fail mode. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc2218e-29c5-4011-8ea5-e36163d58d4c --- CLAUDE.md | 2 +- .../README.md | 2 +- packages/agents-a365-tooling/docs/design.md | 4 +-- .../src/configuration/ToolingConfiguration.ts | 11 ++++++-- .../A365PurviewInterceptor.test.ts | 5 ++-- .../PurviewDlpConfiguration.test.ts | 28 +++++++++++++++++-- 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d80062b..25b7d5ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,7 +225,7 @@ The keyword "Kairo" is legacy and should not appear in any code. Flag and remove | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | Override the Microsoft Graph token scope | Scope string (default: `https://graph.microsoft.com/.default`) | | `A365_PURVIEW_DLP_FAIL_MODE` | Behavior when no Purview verdict is obtained | `open` (default), `closed`; other values are rejected | | `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | Timeout of each Purview evaluation | Whole number (default: 10000, at most 2147481647); other values are rejected | -| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Max characters of text sent to Purview; longer text is sent truncated and follows the fail mode unless blocked | Whole number (default: 100000); other values are rejected | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Max characters of text sent to Purview; longer text is sent truncated and follows the fail mode unless blocked | Whole number (default: 100000, at most 2147483647); other values are rejected | | `A365_PURVIEW_DLP_RESPONSE_MODE` | How replies are handled | `audit` (default, never blocked), `enforce`; other values are rejected | | `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override observability auth scopes | Space-separated scope strings | | `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable Agent365 exporter | `true`, `false` (default) | diff --git a/packages/agents-a365-tooling-extensions-agenthooks/README.md b/packages/agents-a365-tooling-extensions-agenthooks/README.md index f8a95f8b..405ab5d5 100644 --- a/packages/agents-a365-tooling-extensions-agenthooks/README.md +++ b/packages/agents-a365-tooling-extensions-agenthooks/README.md @@ -400,7 +400,7 @@ The interceptor sets the content entry's `correlationId` and `sequenceNumber` fr | `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` (the default) sends replies without waiting and never blocks them; `enforce` blocks them like prompts. Any other value is rejected | | `A365_PURVIEW_DLP_FAIL_MODE` | `closed` blocks when no verdict is obtained; `open` (the default) allows. Any other value is rejected | | `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | deadline of each evaluation, token acquisition included (default 10000, at most 2147481647). A value that is not a whole number (for example `10s`) is rejected | -| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | the longest text sent (default 100000); longer text is sent truncated and, unless Purview blocks it, follows the fail mode. A value that is not a whole number is rejected | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | the longest text sent, a whole number (default 100000, at most 2147483647); longer text is sent truncated and, unless Purview blocks it, follows the fail mode. Any other value is rejected | | `A365_PURVIEW_DLP_GRAPH_BASE_URL` | the Microsoft Graph base URL (default `https://graph.microsoft.com/v1.0`; `https` only) | | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | overrides the Microsoft Graph token scope (default `https://graph.microsoft.com/.default`) | diff --git a/packages/agents-a365-tooling/docs/design.md b/packages/agents-a365-tooling/docs/design.md index 7320247d..d43f8b41 100644 --- a/packages/agents-a365-tooling/docs/design.md +++ b/packages/agents-a365-tooling/docs/design.md @@ -428,7 +428,7 @@ const customConfig = new ToolingConfiguration({ | `purviewDlpAuthenticationScope` | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | `https://graph.microsoft.com/.default` | OAuth scope of the Microsoft Graph token | | `purviewDlpFailClosed` | `A365_PURVIEW_DLP_FAIL_MODE` | `false` (open) | `closed` blocks when no verdict is obtained; values other than `open` and `closed` throw | | `purviewDlpTimeoutMilliseconds` | `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | `10000` | Timeout of each evaluation, token acquisition included; at most 2147481647; the variable must be a whole number | -| `purviewDlpMaxContentCharacters` | `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | `100000` | Maximum characters of text sent; longer text is sent truncated and, unless Purview blocks it, follows the fail mode; the variable must be a whole number | +| `purviewDlpMaxContentCharacters` | `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | `100000` | Maximum characters of text sent; longer text is sent truncated and, unless Purview blocks it, follows the fail mode; at most 2147483647; the variable must be a whole number | | `purviewDlpResponseMode` | `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` | `audit` or `enforce` handling of replies; other values throw | | `clusterCategory` | `CLUSTER_CATEGORY` | `prod` | (Inherited) Environment cluster | | `isDevelopmentEnvironment` | - | Derived | (Inherited) true if cluster is 'local' or 'dev' | @@ -478,7 +478,7 @@ src/ | `A365_PURVIEW_DLP_AUTHENTICATION_SCOPE` | OAuth scope of the Microsoft Graph token | `https://graph.microsoft.com/.default` | | `A365_PURVIEW_DLP_FAIL_MODE` | `closed` blocks when no verdict is obtained; values other than `open` and `closed` are rejected | `open` | | `A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS` | Timeout of each evaluation (at most 2147481647; values that are not whole numbers are rejected) | `10000` | -| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Maximum characters of text sent to Purview; longer text is sent truncated and, unless Purview blocks it, follows the fail mode (values that are not whole numbers are rejected) | `100000` | +| `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` | Maximum characters of text sent to Purview; longer text is sent truncated and, unless Purview blocks it, follows the fail mode (at most 2147483647; values that are not whole numbers are rejected) | `100000` | | `A365_PURVIEW_DLP_RESPONSE_MODE` | `audit` sends replies without waiting and never blocks them; `enforce` blocks them like prompts; other values are rejected | `audit` | ## Error Handling diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts index de1cc0b1..8eb66517 100644 --- a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts @@ -29,6 +29,12 @@ const PURVIEW_DLP_RESPONSE_MODES: ReadonlySet = new Set(['audit', 'enfor */ const MAX_DEFENDER_RTP_MAX_CONTENT_CHARACTERS = 2_147_483_647; +/** + * The largest Purview content limit, as for Defender and in the .NET and Python SDKs: the agent-hooks interceptor's + * reading budget is a multiple of it, and a larger value could overflow it to Infinity, which never runs out. + */ +const MAX_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = 2_147_483_647; + /** Application id of the Defender API, which grants `RealtimeProtection.Evaluate.All`. */ export const DEFENDER_RTP_API_APP_ID = '86a21212-634e-4553-b3d6-e477e4c9d9ec'; @@ -366,6 +372,7 @@ export class ToolingConfiguration extends RuntimeConfiguration { * Maximum characters of the text sent to Purview (default 100000). Longer text is cut and sent with * `isTruncated: true`; Purview's block of it stands, but its allow does not cover the rest, so the * content then follows the fail mode. `A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS` must be a whole number. + * At most 2147483647, as for Defender and in the .NET and Python SDKs. */ get purviewDlpMaxContentCharacters(): number { const override = this.toolingOverrides.purviewDlpMaxContentCharacters?.(); @@ -373,8 +380,8 @@ export class ToolingConfiguration extends RuntimeConfiguration { ?? wholeNumber('A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS') ?? DEFAULT_PURVIEW_DLP_MAX_CONTENT_CHARACTERS; - if (!Number.isInteger(maximum) || maximum <= 0) { - throw sdkError(new Error('purviewDlpMaxContentCharacters must be a positive integer.')); + if (!Number.isInteger(maximum) || maximum <= 0 || maximum > MAX_PURVIEW_DLP_MAX_CONTENT_CHARACTERS) { + throw sdkError(new Error(`purviewDlpMaxContentCharacters must be a positive integer of at most ${MAX_PURVIEW_DLP_MAX_CONTENT_CHARACTERS}.`)); } return maximum; } diff --git a/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts b/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts index 1861f98b..35113d7c 100644 --- a/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts +++ b/tests/tooling-extensions-agenthooks/A365PurviewInterceptor.test.ts @@ -650,7 +650,8 @@ describe('A365PurviewInterceptor under the agent-hooks emitter', () => { purviewDlpMaxContentCharacters: () => maximum, }, }); - maximum = 0; + // Finite, but beyond the cap: it would overflow the reading budget to Infinity. + maximum = Number('9'.repeat(308)); const denied = await emitter.emitUnchecked(builder('conversation-25').input('hello')); failClosed = false; @@ -658,7 +659,7 @@ describe('A365PurviewInterceptor under the agent-hooks emitter', () => { expect(proceeds(denied)).toBe(false); expect(denied.verdict.warnings).toEqual([ - { reason: 'purview:unverified', message: 'Error: purviewDlpMaxContentCharacters must be a positive integer.' }, + { reason: 'purview:unverified', message: 'Error: purviewDlpMaxContentCharacters must be a positive integer of at most 2147483647.' }, ]); expect(proceeds(allowed)).toBe(true); }); diff --git a/tests/tooling/configuration/PurviewDlpConfiguration.test.ts b/tests/tooling/configuration/PurviewDlpConfiguration.test.ts index df27e825..4c1ccef1 100644 --- a/tests/tooling/configuration/PurviewDlpConfiguration.test.ts +++ b/tests/tooling/configuration/PurviewDlpConfiguration.test.ts @@ -101,13 +101,37 @@ describe('Purview DLP tooling configuration', () => { expect(() => new ToolingConfiguration({ purviewDlpTimeoutMilliseconds: () => 0 }).purviewDlpTimeoutMilliseconds) .toThrow('purviewDlpTimeoutMilliseconds must be a positive integer of at most 2147481647.'); expect(() => new ToolingConfiguration({ purviewDlpMaxContentCharacters: () => -1 }).purviewDlpMaxContentCharacters) - .toThrow('purviewDlpMaxContentCharacters must be a positive integer.'); + .toThrow('purviewDlpMaxContentCharacters must be a positive integer of at most 2147483647.'); expect(() => new ToolingConfiguration({ purviewDlpMaxContentCharacters: () => 1.5 }).purviewDlpMaxContentCharacters) - .toThrow('purviewDlpMaxContentCharacters must be a positive integer.'); + .toThrow('purviewDlpMaxContentCharacters must be a positive integer of at most 2147483647.'); process.env.A365_PURVIEW_DLP_TIMEOUT_MILLISECONDS = '0'; + process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = '0'; expect(() => new ToolingConfiguration().purviewDlpTimeoutMilliseconds) .toThrow('purviewDlpTimeoutMilliseconds must be a positive integer of at most 2147481647.'); + expect(() => new ToolingConfiguration().purviewDlpMaxContentCharacters) + .toThrow('purviewDlpMaxContentCharacters must be a positive integer of at most 2147483647.'); + }); + + it('accepts a maximum content size of exactly 2147483647', () => { + expect(new ToolingConfiguration({ purviewDlpMaxContentCharacters: () => 2_147_483_647 }).purviewDlpMaxContentCharacters) + .toBe(2_147_483_647); + + process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = '2147483647'; + expect(new ToolingConfiguration().purviewDlpMaxContentCharacters).toBe(2_147_483_647); + }); + + it.each([ + ['308 nines, which is finite but overflows the reading budget to Infinity', '9'.repeat(308)], + ['309 nines, which is Infinity', '9'.repeat(309)], + ['one above the cap', '2147483648'], + ])('rejects a maximum content size of %s', (_name, value) => { + process.env.A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS = value; + + expect(() => new ToolingConfiguration().purviewDlpMaxContentCharacters) + .toThrow('purviewDlpMaxContentCharacters must be a positive integer of at most 2147483647.'); + expect(() => new ToolingConfiguration({ purviewDlpMaxContentCharacters: () => Number(value) }).purviewDlpMaxContentCharacters) + .toThrow('purviewDlpMaxContentCharacters must be a positive integer of at most 2147483647.'); }); it.each(['soon', '10s', '10seconds', '1.5', '-5', '1e3', '+5', '0x10'])(