From 31289b9ff7e9cbb4347fab79e537dfa547e3d761 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 12:44:51 +0100 Subject: [PATCH 01/16] Add Microsoft Defender for AI real-time protection on agent-hooks Tooling (Microsoft.Agents.A365.Tooling): - DefenderRtpClient sends agent-hooks/0.1 contexts to the Defender prevention endpoint (POST .../v1/protection/evaluate) at the four points Defender evaluates (input, pre_tool_call, post_tool_call, output) and returns its verdict; deny and transform block. Contexts are fitted to Defender's request validation (target equals the point field, spec-only tool_call and tool_result members, UTC timestamp, sanitized framework and extensions) without modifying the host's context. - Calls carry the agent identity's app-only token for the Defender API (RealtimeProtection.Evaluate.All), resolved by a DefenderRtpTokenResolver and cached per agent and tenant. FromAgenticConnection uses the agent's IAgenticTokenProvider connection, the authority Observability S2S uses. - A unique x-ms-correlation-id per call. Failures follow A365_DEFENDER_RTP_FAIL_MODE; a 400 reports the failed validation rule. New preview package Microsoft.Agents.A365.Tooling.Extensions.AgentHooks: - A365DefenderInterceptor, an IInterceptor for ResponsibleAI.AgentHooks 0.1.0-beta.1, and CreateProtectionEmitter (parallel/strictest). Docs: package README (`a365 setup all` grants the Defender role through microsoft/Agent365-devTools#485; the agent's tenant must be onboarded to Microsoft Defender for AI, otherwise 403, which follows the fail mode), CHANGELOG, docs/design.md and the Tooling design doc. Tests: 25 client tests, checked against the endpoint's request validation rules, and 8 interceptor tests under the real native emitter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 21c5fda0-eb1d-4d28-8fe0-f54c61e0fe61 Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- CHANGELOG.md | 14 + docs/design.md | 13 +- src/Directory.Packages.props | 2 + src/Microsoft.Agents.A365.Sdk.sln | 33 + .../Protection/DefenderRtpClientTests.cs | 545 +++++++++++ .../A365DefenderInterceptorTests.cs | 217 +++++ ...Tooling.Extensions.AgentHooks.Tests.csproj | 25 + .../Protection/Defender/DefenderRtpClient.cs | 857 ++++++++++++++++++ .../Defender/DefenderRtpEvaluationResult.cs | 72 ++ .../Protection/Defender/DefenderRtpOptions.cs | 126 +++ .../Defender/DefenderRtpTokenResolver.cs | 67 ++ .../Defender/DefenderRtpTokenResolvers.cs | 79 ++ src/Tooling/Core/docs/design.md | 31 + .../AgentHooks/A365DefenderInterceptor.cs | 169 ++++ ....A365.Tooling.Extensions.AgentHooks.csproj | 28 + src/Tooling/Extensions/AgentHooks/README.md | 82 ++ 16 files changed, 2357 insertions(+), 3 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests.csproj create mode 100644 src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs create mode 100644 src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs create mode 100644 src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs create mode 100644 src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolver.cs create mode 100644 src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs create mode 100644 src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs create mode 100644 src/Tooling/Extensions/AgentHooks/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.csproj create mode 100644 src/Tooling/Extensions/AgentHooks/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b1126c08..f15cf51f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,20 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. `IExporterTokenCache`. ### Added +- **Microsoft.Agents.A365.Tooling** - Microsoft Defender for AI real-time protection client + - `DefenderRtpClient.EvaluateHookContextAsync` sends an agent-hooks/0.1 context to the Defender + prevention endpoint (`POST .../v1/protection/evaluate`) at the four points Defender evaluates + (`input`, `pre_tool_call`, `post_tool_call`, `output`) and returns its verdict (`deny` and + `transform` block). The context is fitted to Defender's request validation without modifying it. + - Calls carry the agent identity's own app-only token for the Defender API + (`api://86a21212-634e-4553-b3d6-e477e4c9d9ec`, role `RealtimeProtection.Evaluate.All`), resolved by a + `DefenderRtpTokenResolver` and cached per agent and tenant; `DefenderRtpTokenResolvers.FromAgenticConnection` + uses the agent's connection (`IAgenticTokenProvider`), the same authority as Observability S2S export. + - Every call sends a unique `x-ms-correlation-id`. Failures follow `A365_DEFENDER_RTP_FAIL_MODE`, and a `400` + reports the failed validation rule. +- **Microsoft.Agents.A365.Tooling.Extensions.AgentHooks** (new, preview) - `A365DefenderInterceptor`, an + agent-hooks interceptor (`ResponsibleAI.AgentHooks` 0.1.0-beta.1) for Defender, and + `A365AgentHooks.CreateProtectionEmitter` (`parallel/strictest`). - **Microsoft.Agents.A365.Tooling** - V1/V2 per-audience token support for MCP servers - `MCPServerConfig` extended with `audience`, `scope`, `publisher`, and `Headers` fields - `IMcpTokenProvider` interface for pluggable OAuth token acquisition diff --git a/docs/design.md b/docs/design.md index d36000c3..4b5a13f7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -35,7 +35,8 @@ src/ │ └── Extensions/ │ ├── SemanticKernel/ # SK tool registration │ ├── AgentFramework/ # AF tool registration -│ └── AzureAIFoundry/ # Azure AI Foundry integration +│ ├── AzureAIFoundry/ # Azure AI Foundry integration +│ └── AgentHooks/ # agent-hooks Defender interceptor (preview) └── Tests/ # Test projects docs/ └── design.md # This file @@ -52,10 +53,11 @@ docs/ | `Microsoft.Agents.A365.Observability.Extensions.SemanticKernel` | Semantic Kernel telemetry integration | [design.md](../src/Observability/Extensions/SemanticKernel/docs/design.md) | | `Microsoft.Agents.A365.Observability.Extensions.OpenAI` | OpenAI SDK telemetry integration | [design.md](../src/Observability/Extensions/OpenAI/docs/design.md) | | `Microsoft.Agents.A365.Notifications` | Microsoft 365 notification handling | [design.md](../src/Notification/Microsoft.Agents.A365.Notifications/docs/design.md) | -| `Microsoft.Agents.A365.Tooling` | MCP server discovery and configuration | [design.md](../src/Tooling/Core/docs/design.md) | +| `Microsoft.Agents.A365.Tooling` | MCP server discovery and configuration; Microsoft Defender for AI real-time protection client | [design.md](../src/Tooling/Core/docs/design.md) | | `Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel` | Semantic Kernel MCP tool registration | [design.md](../src/Tooling/Extensions/SemanticKernel/docs/design.md) | | `Microsoft.Agents.A365.Tooling.Extensions.AgentFramework` | Agent Framework MCP tool registration | [design.md](../src/Tooling/Extensions/AgentFramework/docs/design.md) | | `Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry` | Azure AI Foundry MCP tool registration | [design.md](../src/Tooling/Extensions/AzureAIFoundry/docs/design.md) | +| `Microsoft.Agents.A365.Tooling.Extensions.AgentHooks` | Microsoft Defender for AI interceptor for agent-hooks hosts (preview) | [README.md](../src/Tooling/Extensions/AgentHooks/README.md) | ## Core Package Documentation @@ -335,6 +337,9 @@ else if (EnvironmentUtils.IsDevelopmentEnvironment()) | `EnableAgent365Exporter` | Enable Agent365 telemetry exporter | `false` | | `ASPNETCORE_ENVIRONMENT` | Environment name (Development, Production) | - | | `SuppressInvokeAgentInput` | Suppress input messages in invoke_agent spans | `false` | +| `ENABLE_A365_DEFENDER_RTP` | Enable Microsoft Defender for AI real-time protection (`DefenderRtpOptions.FromEnvironment`) | `false` | +| `A365_DEFENDER_RTP_ENDPOINT` | Defender prevention endpoint, `https:///v1/protection/evaluate` | - | +| `A365_DEFENDER_RTP_FAIL_MODE` | `closed` blocks when Defender returns no verdict | open | ## Testing @@ -349,9 +354,11 @@ src/Tests/ ├── Microsoft.Agents.A365.Observability.Extension.Tests/ ├── Microsoft.Agents.A365.Notifications.Tests/ ├── Microsoft.Agents.A365.Tooling.Tests/ +├── Microsoft.Agents.A365.Tooling.Core.Tests/ ├── Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Tests/ ├── Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Tests/ -└── Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/ +├── Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/ +└── Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/ ``` Run tests with: diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 40e71f11..f151be67 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -40,6 +40,8 @@ + + + Microsoft.Agents.A365.Tooling.Extensions.AgentHooks + Microsoft Agent 365 agent-hooks protection + Microsoft Agent 365 real-time protection on the agent-hooks control contract (AGENT-HOOKS-0.1): a Microsoft Defender for AI interceptor for any agent-hooks host. + Microsoft Agent 365 agent-hooks protection - Microsoft Defender for AI interceptor + Microsoft Agent 365 Tooling SDK + Microsoft;Agent365;A365;AgentHooks;Defender;Security;AI;Agents + + $(NoWarn);NU5104 + + + + + + + + + + + + diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md new file mode 100644 index 00000000..5e65a235 --- /dev/null +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -0,0 +1,82 @@ +# Microsoft.Agents.A365.Tooling.Extensions.AgentHooks + +Microsoft Agent 365 real-time protection on the [agent-hooks](https://github.com/responsibleai/agent-hooks) +control contract (AGENT-HOOKS-0.1), using the .NET package +[`ResponsibleAI.AgentHooks`](https://www.nuget.org/packages/ResponsibleAI.AgentHooks). + +`A365DefenderInterceptor` is an agent-hooks interceptor for Microsoft Defender for AI. Each context the host +emits at the four points Defender evaluates is sent to the prevention endpoint +(`POST .../v1/protection/evaluate`) as-is, keeping its session, sequence and tool call ids. Defender's verdict +decides: + +| agent-hooks point | When | On `deny` | +|---|---|---| +| `input` | the user's message, before the agent runs | the agent does not run | +| `pre_tool_call` | a tool call, before it runs | the tool does not run | +| `post_tool_call` | a tool result, before the agent uses it | the result is withheld | +| `output` | the reply, before it is sent | the reply is replaced | + +Other points (`agent_startup`, model calls, `agent_shutdown`) are allowed without a call. + +## Authentication + +Calls carry the **agent identity's own app-only token** in the agent's tenant, for the Defender API +(`api://86a21212-634e-4553-b3d6-e477e4c9d9ec`, role `RealtimeProtection.Evaluate.All`). This is the same +authority as Observability S2S export: `DefenderRtpTokenResolvers.FromAgenticConnection` asks the agent's +connection (`IAgenticTokenProvider`) for the agent identity's assertion and exchanges it. `a365 setup all` grants +the role to the blueprint as an inheritable permission, so every agent identity under it inherits it +([microsoft/Agent365-devTools#485](https://github.com/microsoft/Agent365-devTools/pull/485)). Defender also +requires the agent's tenant to be onboarded to Microsoft Defender for AI; otherwise it answers `403`, which +follows the fail mode. + +## Usage + +```csharp +using AgentHooks; +using Microsoft.Agents.A365.Tooling.Extensions.AgentHooks; +using Microsoft.Agents.A365.Tooling.Protection.Defender; + +var defender = new DefenderRtpClient(DefenderRtpOptions.FromEnvironment(), httpClient); +var tokens = DefenderRtpTokenResolvers.FromAgenticConnection( + (IAgenticTokenProvider)connections.GetDefaultConnection(), httpClient); + +var emitter = A365AgentHooks.CreateProtectionEmitter(defender: defender.Options) + .AddA365Defender(new A365DefenderInterceptor( + defender, + context => new A365DefenderCall( + new DefenderRtpAgentContext + { + AgentId = turnContext.Activity.Recipient.AgenticAppId, // the agent identity + TenantId = turnContext.Activity.Recipient.TenantId, // the agent's tenant + RequestId = turnContext.Activity.Id, + UserId = turnContext.Activity.From.AadObjectId, + }, + tokens), + result => logger.LogInformation( + "Defender {Point} allowed={Allowed} evaluated={Evaluated} cid={CorrelationId}", + result.InterceptionPoint, result.Allowed, result.Evaluated, result.CorrelationId))); + +var builder = new AgentContextBuilder(agentId, "agent-framework", sessionId, agentName); +var record = await emitter.EmitUncheckedAsync(builder.Input(userMessage), cancellationToken); +if (!record.Proceeds) { /* blocked: record.Verdict.Message */ } +``` + +Agents built on Microsoft Agent Framework can register the same interceptor with +`Microsoft.Agents.AI.AgentHooks`, which mediates model and tool calls. + +## Configuration + +| Variable | Meaning | +|---|---| +| `ENABLE_A365_DEFENDER_RTP` | `true` to call Defender | +| `A365_DEFENDER_RTP_ENDPOINT` | the prevention endpoint, `https:///v1/protection/evaluate` | +| `A365_DEFENDER_RTP_FAIL_MODE` | `closed` blocks when no verdict is obtained; default is open | +| `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | per-call timeout (default 10000) | +| `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | overrides the Defender API scope | +| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | clamps each string sent (default 20000) | + +Every call sends a unique `x-ms-correlation-id`, returned as `DefenderRtpEvaluationResult.CorrelationId`; +Defender logs each evaluation under it. A `400` reports the failed validation rule in `Error`. + +`ResponsibleAI.AgentHooks` is a prerelease package with a native core (`agent_hooks_ffi`) for linux-x64, +win-x64, osx-x64 and osx-arm64. From 51a8f29afac735a99fb750e1210824685d887d51 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 13:17:32 +0100 Subject: [PATCH 02/16] Address review findings for Defender RTP - One deadline (DefenderRtpOptions.Timeout) now covers token acquisition and the request, so the fail mode applies before an agent-hooks interceptor timeout; the README explains how to size the emitter's timeout. - Token acquisitions leave the in-flight map as they complete, so a cancelled caller cannot strand one and a failed or stale acquisition is never reused. Within five minutes of expiry the cached token is refreshed in the background and kept until it actually expires if the refresh fails. - Any transport exception (for example from a resilience handler) is a not-evaluated result that follows the fail mode. - The endpoint and the token authority must be absolute HTTPS URLs. - MaxContentCharacters applies to every content string (messages, tool declarations, extensions and host-added members); ids, names and roles are not truncated. - FromAgenticConnection reports a malformed token response clearly; new tests cover its URL, form fields, assertion, errors and cancellation. - Docs: Defender receives a fitted copy of the context; until microsoft/Agent365-devTools#485 ships, grant RealtimeProtection.Evaluate.All manually. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- CHANGELOG.md | 9 +- .../Protection/DefenderRtpClientTests.cs | 264 +++++++++++++++++- .../DefenderRtpTokenResolversTests.cs | 170 +++++++++++ .../A365DefenderInterceptorTests.cs | 49 +++- .../Protection/Defender/DefenderRtpClient.cs | 165 +++++++++-- .../Protection/Defender/DefenderRtpOptions.cs | 32 ++- .../Defender/DefenderRtpTokenResolver.cs | 3 +- .../Defender/DefenderRtpTokenResolvers.cs | 25 +- .../AgentHooks/A365DefenderInterceptor.cs | 23 +- src/Tooling/Extensions/AgentHooks/README.md | 39 ++- 10 files changed, 703 insertions(+), 76 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f15cf51f..b3086f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,11 +74,14 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. - `DefenderRtpClient.EvaluateHookContextAsync` sends an agent-hooks/0.1 context to the Defender prevention endpoint (`POST .../v1/protection/evaluate`) at the four points Defender evaluates (`input`, `pre_tool_call`, `post_tool_call`, `output`) and returns its verdict (`deny` and - `transform` block). The context is fitted to Defender's request validation without modifying it. + `transform` block). A copy of the context is fitted to Defender's request validation; the host's context is + not modified. One deadline (`A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS`) covers token acquisition and the + request, and the endpoint and token authority must be absolute HTTPS URLs. - Calls carry the agent identity's own app-only token for the Defender API (`api://86a21212-634e-4553-b3d6-e477e4c9d9ec`, role `RealtimeProtection.Evaluate.All`), resolved by a - `DefenderRtpTokenResolver` and cached per agent and tenant; `DefenderRtpTokenResolvers.FromAgenticConnection` - uses the agent's connection (`IAgenticTokenProvider`), the same authority as Observability S2S export. + `DefenderRtpTokenResolver`, cached per agent and tenant and refreshed in the background before it expires; + `DefenderRtpTokenResolvers.FromAgenticConnection` uses the agent's connection (`IAgenticTokenProvider`), the + same authority as Observability S2S export. - Every call sends a unique `x-ms-correlation-id`. Failures follow `A365_DEFENDER_RTP_FAIL_MODE`, and a `400` reports the failed validation rule. - **Microsoft.Agents.A365.Tooling.Extensions.AgentHooks** (new, preview) - `A365DefenderInterceptor`, an diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 10831620..6d9e775a 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using System.Net; using System.Text; using System.Text.Json.Nodes; @@ -182,15 +183,51 @@ public async Task RepairsLooselyFilledOptionalFields() } [Fact] - public async Task ClampsLongStrings() + public async Task ClampsEveryContentStringButNotTheEnvelope() { var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 4 }); + var context = InputContext("abcdefgh"); + context["messages"] = new JsonArray(new JsonObject { ["role"] = "assistant", ["content"] = "previous reply" }); + context["tools"] = new JsonArray(new JsonObject { ["name"] = "search_web", ["description"] = "Searches the web." }); + context["extensions"] = new JsonObject { ["custom"] = new JsonObject { ["note"] = "long extension text" } }; + context["metadata"] = "host-added value"; - await client.EvaluateHookContextAsync(InputContext("abcdefgh"), Agent, tokens.Resolve); + await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); body["input"]!["content"]!.GetValue().Should().Be("abcd...[truncated 4 chars]"); + body["messages"]![0]!["content"]!.GetValue().Should().Be("prev...[truncated 10 chars]"); + body["tools"]![0]!["description"]!.GetValue().Should().Be("Sear...[truncated 13 chars]"); + body["extensions"]!["custom"]!["note"]!.GetValue().Should().Be("long...[truncated 15 chars]"); + body["metadata"]!.GetValue().Should().Be("host...[truncated 12 chars]"); + body["messages"]![0]!["role"]!.GetValue().Should().Be("assistant"); + body["tools"]![0]!["name"]!.GetValue().Should().Be("search_web"); + body["agent"]!["name"]!.GetValue().Should().Be("SampleAgent"); + body["session"]!["id"]!.GetValue().Should().Be("conversation:activity"); + } + + [Fact] + public async Task ClampsToolContentButNotTheToolIdentity() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 4 }); + var context = JsonNode.Parse($$""" + { + "spec": "agent-hooks/0.1", "interception_point": "post_tool_call", "timestamp": "2026-10-07T10:00:00.000Z", "sequence": 4, + "agent": { "id": "{{AgentId}}", "framework": "agent365" }, + "session": { "id": "s-1" }, + "tool_call": { "id": "call_42", "name": "SearchFlights", "args": { "query": "Seattle to Paris" } }, + "tool_result": { "value": "three results", "is_error": false } + } + """)!.AsObject(); + + await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + var body = handler.Calls.Single().Body; + DefenderContract.Errors(body).Should().BeEmpty(); + body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{"query":"Seat...[truncated 12 chars]"}}"""); + body["tool_result"]!["value"]!.GetValue().Should().Be("thre...[truncated 9 chars]"); + JsonNode.DeepEquals(body["target"], body["tool_result"]!["value"]).Should().BeTrue(); } [Fact] @@ -262,7 +299,7 @@ public async Task TreatsTransformAsABlock() public async Task FollowsTheFailModeOnAnHttpErrorAndKeepsTheServiceDetail(bool failClosed) { var (client, _, tokens) = Create( - _ => Json(new { title = "Forbidden", status = 403, detail = "The calling application is not allowed to use the third-party prevention endpoint." }, HttpStatusCode.Forbidden), + _ => Json(new { title = "Forbidden", status = 403, detail = "The caller is not authorized for real-time protection." }, HttpStatusCode.Forbidden), new DefenderRtpOptions { FailClosed = failClosed }); var result = await client.EvaluateHookContextAsync(InputContext("hello"), Agent, tokens.Resolve); @@ -270,7 +307,7 @@ public async Task FollowsTheFailModeOnAnHttpErrorAndKeepsTheServiceDetail(bool f result!.Evaluated.Should().BeFalse(); result.Allowed.Should().Be(!failClosed); result.HttpStatus.Should().Be(403); - result.Error.Should().Be("http 403: The calling application is not allowed to use the third-party prevention endpoint."); + result.Error.Should().Be("http 403: The caller is not authorized for real-time protection."); (result.BlockReason != null).Should().Be(failClosed); } @@ -280,7 +317,7 @@ public async Task ReportsTheFailedValidationRulesOfA400() var (client, _, tokens) = Create(_ => Json(new { errorCode = 40001, - message = "The request contains validation errors. Please raise a support ticket.", + message = "The request contains validation errors.", httpStatus = 400, diagnostics = """{"validationErrors":[{"field":"input","message":"The target field must match input."},{"field":"Target","message":"The target field must match input."}]}""", }, HttpStatusCode.BadRequest)); @@ -318,6 +355,62 @@ public async Task ReportsATimeout() result.Error.Should().Be("request timeout"); } + [Fact] + public async Task SharesOneDeadlineBetweenTokenAcquisitionAndTheRequest() + { + var requestWait = TimeSpan.Zero; + var handler = new RecordingHandler(async (_, cancellationToken) => + { + var waiting = Stopwatch.StartNew(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + finally + { + requestWait = waiting.Elapsed; + } + + return Json(new { decision = "allow" }); + }); + var client = new DefenderRtpClient(Options(new DefenderRtpOptions { Timeout = TimeSpan.FromSeconds(1) }), new HttpClient(handler)); + var tokens = new TokenSource(delay: TimeSpan.FromMilliseconds(700)); + + var result = await client.EvaluateHookContextAsync(InputContext("hello"), Agent, tokens.Resolve); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().BeTrue(); + result.Error.Should().BeOneOf("request timeout", "entra token timeout"); + requestWait.Should().BeLessThan(TimeSpan.FromMilliseconds(700), "the request only gets what is left of the one deadline"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeOnAnyTransportException(bool failClosed) + { + var (client, _, tokens) = Create(_ => throw new CircuitOpenException(), new DefenderRtpOptions { FailClosed = failClosed }); + + var result = await client.EvaluateHookContextAsync(InputContext("hello"), Agent, tokens.Resolve); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().Be(!failClosed); + result.Error.Should().Be("request failed (CircuitOpenException)"); + } + + [Fact] + public async Task DoesNotSendToAnEndpointChangedToHttpAfterConstruction() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" })); + client.Options.Endpoint = new Uri("http://prevention.example.test/v1/protection/evaluate"); + + var result = await client.EvaluateHookContextAsync(InputContext("hello"), Agent, tokens.Resolve); + + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("endpoint is not an absolute https URL"); + handler.Calls.Should().BeEmpty(); + } + // ─── authentication ────────────────────────────────────────────────────── [Fact] @@ -360,6 +453,99 @@ public async Task FollowsTheFailModeWhenNoTokenCanBeAcquired(bool failClosed) handler.Calls.Should().BeEmpty(); } + [Fact] + public async Task StartsAFreshAcquisitionAfterAnAbandonedOneFails() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" })); + var release = new TaskCompletionSource(); + var attempts = 0; + DefenderRtpTokenResolver resolver = async (agentId, tenantId, scopes, cancellationToken) => + { + if (Interlocked.Increment(ref attempts) == 1) + { + await release.Task.ConfigureAwait(false); + throw new InvalidOperationException("transient failure"); + } + + return await tokens.Resolve(agentId, tenantId, scopes, cancellationToken); + }; + using var caller = new CancellationTokenSource(); + + var abandoned = client.EvaluateHookContextAsync(InputContext("one"), Agent, resolver, caller.Token); + caller.Cancel(); + Func waitForAbandoned = () => abandoned; + await waitForAbandoned.Should().ThrowAsync(); + release.SetResult(); + await Task.Delay(50); + + var result = await client.EvaluateHookContextAsync(InputContext("two"), Agent, resolver); + + attempts.Should().Be(2, "the failed acquisition is not handed to the next caller"); + result!.Evaluated.Should().BeTrue(); + handler.Calls.Should().ContainSingle(); + } + + [Fact] + public async Task DoesNotReuseAnAbandonedAcquisitionAfterItsTokenExpires() + { + var clock = new ManualClock(); + var handler = new RecordingHandler(_ => Json(new { decision = "allow" })); + var client = new DefenderRtpClient(Options(), new HttpClient(handler), timeProvider: clock); + var tokens = new TokenSource(() => clock.Now); + var release = new TaskCompletionSource(); + DefenderRtpTokenResolver resolver = async (agentId, tenantId, scopes, cancellationToken) => + { + if (tokens.Requests.Count == 0) + { + await release.Task.ConfigureAwait(false); + } + + return await tokens.Resolve(agentId, tenantId, scopes, cancellationToken); + }; + using var caller = new CancellationTokenSource(); + + var abandoned = client.EvaluateHookContextAsync(InputContext("one"), Agent, resolver, caller.Token); + caller.Cancel(); + Func waitForAbandoned = () => abandoned; + await waitForAbandoned.Should().ThrowAsync(); + release.SetResult(); + await Task.Delay(50); + var expired = tokens.Token; + clock.Now += TimeSpan.FromHours(2); + + var result = await client.EvaluateHookContextAsync(InputContext("two"), Agent, resolver); + + result!.Evaluated.Should().BeTrue(); + tokens.Token.Should().NotBe(expired); + handler.Calls.Single().Authorization.Should().Be($"Bearer {tokens.Token}"); + } + + [Fact] + public async Task KeepsTheCachedTokenUntilItExpiresWhenAnEarlyRefreshFails() + { + var clock = new ManualClock(); + var handler = new RecordingHandler(_ => Json(new { decision = "allow" })); + var client = new DefenderRtpClient(Options(), new HttpClient(handler), timeProvider: clock); + var tokens = new TokenSource(() => clock.Now); + await client.EvaluateHookContextAsync(InputContext("one"), Agent, tokens.Resolve); + var cached = tokens.Token; + tokens.Failure = new InvalidOperationException("Entra is unavailable"); + + clock.Now += TimeSpan.FromMinutes(57); + var withinRefreshWindow = await client.EvaluateHookContextAsync(InputContext("two"), Agent, tokens.Resolve); + + withinRefreshWindow!.Evaluated.Should().BeTrue(); + handler.Calls[1].Authorization.Should().Be($"Bearer {cached}"); + tokens.Requests.Should().HaveCount(2, "an early refresh was attempted in the background"); + + clock.Now += TimeSpan.FromMinutes(4); + var afterExpiry = await client.EvaluateHookContextAsync(InputContext("three"), Agent, tokens.Resolve); + + afterExpiry!.Evaluated.Should().BeFalse(); + afterExpiry.Error.Should().Be("entra token unavailable"); + handler.Calls.Should().HaveCount(2); + } + // ─── options ───────────────────────────────────────────────────────────── [Fact] @@ -393,6 +579,18 @@ public void RequiresAnEndpointWhenEnabled() act.Should().Throw().WithMessage("*A365_DEFENDER_RTP_ENDPOINT*"); } + [Theory] + [InlineData("http://prevention.example.test/v1/protection/evaluate")] + [InlineData("v1/protection/evaluate")] + public void RejectsAnEndpointThatIsNotAbsoluteHttps(string endpoint) + { + var act = () => new DefenderRtpClient(new DefenderRtpOptions { Enabled = true, Endpoint = new Uri(endpoint, UriKind.RelativeOrAbsolute) }); + var fromEnvironment = () => DefenderRtpOptions.FromEnvironment(name => name == "A365_DEFENDER_RTP_ENDPOINT" ? endpoint : null); + + act.Should().Throw().WithMessage("*absolute HTTPS URL*"); + fromEnvironment.Should().Throw().WithMessage("*absolute HTTPS URL*"); + } + // ─── helpers ───────────────────────────────────────────────────────────── internal static JsonObject InputContext(string text) => JsonNode.Parse($$""" @@ -427,27 +625,69 @@ private static (DefenderRtpClient Client, RecordingHandler Handler, TokenSource }; } -/// Resolves a JWT with an hour of lifetime and records each request. +/// +/// Resolves a distinct JWT with an hour of lifetime from the given clock, optionally after a delay or by +/// throwing , and records each request. +/// internal sealed class TokenSource { - public string Token { get; } = CreateToken(); + private readonly Func _now; + private readonly TimeSpan _delay; + + public TokenSource(Func? now = null, TimeSpan delay = default) + { + _now = now ?? (() => DateTimeOffset.UtcNow); + _delay = delay; + Token = CreateToken(_now()); + } + + /// The last token issued. + public string Token { get; private set; } public List Requests { get; } = new(); - public Task Resolve(string agentId, string tenantId, string[] scopes, CancellationToken cancellationToken) + /// When set, each resolution throws this instead of issuing a token. + public Exception? Failure { get; set; } + + public async Task Resolve(string agentId, string tenantId, string[] scopes, CancellationToken cancellationToken) { Requests.Add($"{agentId}|{tenantId}|{string.Join(" ", scopes)}"); - return Task.FromResult(Token); + if (_delay > TimeSpan.Zero) + { + await Task.Delay(_delay, cancellationToken); + } + + if (Failure is { } failure) + { + throw failure; + } + + Token = CreateToken(_now()); + return Token; } - private static string CreateToken() + private static string CreateToken(DateTimeOffset now) { static string Encode(string json) => Convert.ToBase64String(Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - var exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); - return $"{Encode("""{"alg":"none"}""")}.{Encode($$"""{"exp":{{exp}},"roles":["RealtimeProtection.Evaluate.All"]}""")}.signature"; + var exp = now.AddHours(1).ToUnixTimeSeconds(); + var id = Guid.NewGuid().ToString("N"); + return $"{Encode("""{"alg":"none"}""")}.{Encode($$"""{"exp":{{exp}},"jti":"{{id}}","roles":["RealtimeProtection.Evaluate.All"]}""")}.signature"; } } +/// A clock the test moves by hand; timers still run in real time. +internal sealed class ManualClock : TimeProvider +{ + public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => Now; +} + +/// Stands in for a resilience handler's exception, such as an open circuit. +internal sealed class CircuitOpenException : Exception +{ +} + /// Captures each request (headers and body are read before the client disposes the request). internal sealed class RecordingHandler : HttpMessageHandler { diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs new file mode 100644 index 00000000..3807f1d8 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using System.Web; +using FluentAssertions; +using Microsoft.Agents.A365.Tooling.Protection.Defender; +using Microsoft.Agents.Authentication; +using Moq; +using Xunit; + +namespace Microsoft.Agents.A365.Tooling.Core.Tests.Protection; + +public class DefenderRtpTokenResolversTests +{ + private const string AgentId = "aaaaaaaa-0000-4000-8000-000000000001"; + private const string TenantId = "bbbbbbbb-0000-4000-8000-000000000002"; + private const string Assertion = "agent-identity-assertion"; + private static readonly string[] Scopes = { DefenderRtpOptions.DefaultAuthenticationScope }; + + [Fact] + public async Task ExchangesTheAgentIdentityAssertionForTheDefenderToken() + { + var connection = Connection(Assertion); + var endpoint = new TokenEndpoint(_ => Json("""{"token_type":"Bearer","expires_in":3599,"access_token":"defender-token"}""")); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(connection.Object, new HttpClient(endpoint)); + + var token = await resolver(AgentId, TenantId, Scopes, CancellationToken.None); + + token.Should().Be("defender-token"); + connection.Verify(provider => provider.GetAgenticApplicationTokenAsync(TenantId, AgentId, It.IsAny()), Times.Once); + var request = endpoint.Requests.Should().ContainSingle().Subject; + request.Method.Should().Be(HttpMethod.Post); + request.Uri.Should().Be(new Uri($"https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token")); + request.Form.Should().BeEquivalentTo(new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = AgentId, + ["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ["client_assertion"] = Assertion, + ["scope"] = "api://86a21212-634e-4553-b3d6-e477e4c9d9ec/.default", + }); + } + + [Fact] + public async Task UsesTheGivenAuthority() + { + var endpoint = new TokenEndpoint(_ => Json("""{"access_token":"defender-token"}""")); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(endpoint), "https://login.example.test/"); + + await resolver(AgentId, TenantId, Scopes, CancellationToken.None); + + endpoint.Requests.Single().Uri.Should().Be(new Uri($"https://login.example.test/{TenantId}/oauth2/v2.0/token")); + } + + [Theory] + [InlineData("http://login.example.test")] + [InlineData("login.example.test")] + [InlineData("")] + public void RejectsAnAuthorityThatIsNotAbsoluteHttps(string authority) + { + var act = () => DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(), authority); + + act.Should().Throw().WithParameterName("authority"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task FailsWithoutAnAssertion(string? assertion) + { + var endpoint = new TokenEndpoint(_ => Json("""{"access_token":"defender-token"}""")); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(assertion).Object, new HttpClient(endpoint)); + + var act = () => resolver(AgentId, TenantId, Scopes, CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*no agent identity assertion*"); + endpoint.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task FailsOnAnErrorStatusWithoutEchoingTheResponse() + { + var endpoint = new TokenEndpoint(_ => Json( + $$"""{"error":"invalid_client","error_description":"Assertion {{Assertion}} was rejected."}""", + HttpStatusCode.Unauthorized)); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(endpoint)); + + var act = () => resolver(AgentId, TenantId, Scopes, CancellationToken.None); + + var failure = await act.Should().ThrowAsync(); + failure.Which.Message.Should().Contain("HTTP 401").And.NotContain(Assertion).And.NotContain("invalid_client"); + } + + [Theory] + [InlineData("not json", "*not valid JSON*")] + [InlineData("""{"token_type":"Bearer"}""", "*no access_token*")] + [InlineData("""{"access_token":42}""", "*no access_token*")] + public async Task FailsOnAMalformedSuccessResponse(string body, string message) + { + var endpoint = new TokenEndpoint(_ => Json(body)); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(endpoint)); + + var act = () => resolver(AgentId, TenantId, Scopes, CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage(message); + } + + [Fact] + public async Task StopsWhenCancelled() + { + var endpoint = new TokenEndpoint(async (_, cancellationToken) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return Json("""{"access_token":"defender-token"}"""); + }); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(endpoint)); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + var act = () => resolver(AgentId, TenantId, Scopes, cancellation.Token); + + await act.Should().ThrowAsync(); + } + + private static Mock Connection(string? assertion) + { + var connection = new Mock(); + connection + .Setup(provider => provider.GetAgenticApplicationTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(assertion!); + return connection; + } + + private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) => new(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; +} + +/// A fake token endpoint that records each form-encoded request. +internal sealed class TokenEndpoint : HttpMessageHandler +{ + private readonly Func> _respond; + + public TokenEndpoint(Func respond) + : this((request, _) => Task.FromResult(respond(request))) + { + } + + public TokenEndpoint(Func> respond) + { + _respond = respond; + } + + public List Requests { get; } = new(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content == null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + var form = HttpUtility.ParseQueryString(body); + Requests.Add(new TokenRequest( + request.Method, + request.RequestUri!, + form.AllKeys.OfType().ToDictionary(key => key, key => form[key] ?? string.Empty))); + return await _respond(request, cancellationToken); + } +} + +internal sealed record TokenRequest(HttpMethod Method, Uri Uri, Dictionary Form); diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index 33951aa9..5df4dab2 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -87,14 +87,37 @@ public void MapsADefenderDenyToAnAgentHooksVerdictWithEvidenceAndLabels() [Fact] public async Task AllowsWithAWarningWhenFailOpenDefenderIsUnavailable() { - var harness = new Harness(_ => Json(new { title = "Forbidden", detail = "The calling application is not allowed to use the third-party prevention endpoint." }, HttpStatusCode.Forbidden)); + var harness = new Harness(_ => Json(new { title = "Forbidden", detail = "The caller is not authorized for real-time protection." }, HttpStatusCode.Forbidden)); var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-2"); var record = await harness.Emitter.EmitUncheckedAsync(builder.Output(JsonValue.Create("Here are three flights.")!), CancellationToken.None); record.Proceeds.Should().BeTrue(); record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "defender:unverified" - && warning.Message != null && warning.Message.Contains("not allowed to use the third-party prevention endpoint")); + && warning.Message != null && warning.Message.Contains("not authorized for real-time protection")); + } + + [Fact] + public async Task AppliesTheFailModeBeforeTheInterceptorTimeout() + { + var options = new DefenderRtpOptions { Enabled = true, Endpoint = new Uri(Endpoint), Timeout = TimeSpan.FromSeconds(1) }; + var client = new DefenderRtpClient(options, new HttpClient(new HangingEndpoint())); + DefenderRtpTokenResolver slowToken = async (_, _, _, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(900), cancellationToken); + return Token; + }; + var agent = new DefenderRtpAgentContext { AgentId = AgentId, TenantId = TenantId }; + + // 0.9 s for the token plus a fresh 1 s for the request would outlast this 1.6 s interceptor timeout. + var emitter = A365AgentHooks.CreateProtectionEmitter(TimeSpan.FromMilliseconds(1600), options) + .AddA365Defender(new A365DefenderInterceptor(client, _ => new A365DefenderCall(agent, slowToken))); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-7"); + + var record = await emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + record.Proceeds.Should().BeTrue("Defender is configured to fail open"); + record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "defender:unverified"); } [Fact] @@ -150,6 +173,15 @@ public async Task AllowsWithoutACallWhenNoIdentityIsResolved() harness.Bodies.Should().BeEmpty(); } + private static string Token + { + get + { + static string Encode(string json) => Convert.ToBase64String(Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return $"{Encode("""{"alg":"none"}""")}.{Encode($$"""{"exp":{{DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds()}}}""")}.sig"; + } + } + private static HttpResponseMessage Json(object body, HttpStatusCode status = HttpStatusCode.OK) => new(status) { Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"), @@ -182,14 +214,15 @@ public Harness( public List CorrelationIds { get; } = new(); public List Evaluations { get; } = new(); + } - private static string Token + /// An endpoint that never answers, so only a deadline ends the request. + private sealed class HangingEndpoint : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - get - { - static string Encode(string json) => Convert.ToBase64String(Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - return $"{Encode("""{"alg":"none"}""")}.{Encode($$"""{"exp":{{DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds()}}}""")}.sig"; - } + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK); } } diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index df9f5611..f1957ee9 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -46,6 +46,13 @@ public sealed class DefenderRtpClient private static readonly Regex ExtensionKeyPattern = new("^[a-z][a-z0-9_]*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex InvalidFrameworkCharacters = new("[^a-z0-9_-]+", RegexOptions.Compiled | RegexOptions.CultureInvariant); + // Envelope members and members with dedicated handling; any other member a host adds is content. + private static readonly HashSet KnownMembers = new(StringComparer.Ordinal) + { + "spec", "interception_point", "timestamp", "sequence", "request_id", "session", "agent", "tenant", "actor", "model", + "input", "output", "target", "tool_call", "tool_result", "messages", "tools", "extensions", + }; + private readonly HttpClient _httpClient; private readonly Func _idFactory; private readonly TimeProvider _timeProvider; @@ -126,10 +133,21 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => var sessionId = ReadString(hook["session"]?["id"]); var started = _timeProvider.GetTimestamp(); + // One deadline covers token acquisition and the request, so the fail mode applies within + // Options.Timeout, before an agent-hooks interceptor timeout would. + using var timeout = new CancellationTokenSource(Options.Timeout, _timeProvider); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + string? token; + var tokenError = "entra token unavailable"; try { - token = await GetAccessTokenAsync(agent, tokenResolver, cancellationToken).ConfigureAwait(false); + token = await GetAccessTokenAsync(agent, tokenResolver, deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + token = null; + tokenError = "entra token timeout"; } catch (Exception) when (!cancellationToken.IsCancellationRequested) { @@ -138,16 +156,16 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => if (string.IsNullOrEmpty(token)) { - return Unavailable(point!, "entra token unavailable", sessionId, null, _timeProvider.GetElapsedTime(started)); + return Unavailable(point!, tokenError, sessionId, null, _timeProvider.GetElapsedTime(started)); } - return await PostAsync(hook, point!, sessionId, token!, started, cancellationToken).ConfigureAwait(false); + return await PostAsync(hook, point!, sessionId, token!, started, deadline.Token, cancellationToken).ConfigureAwait(false); } /// /// Acquires and caches the agent identity's Defender token without evaluating anything, so the - /// first evaluation does not wait for Entra. A cached token is reused until five minutes before it - /// expires. Throws when no token can be acquired. + /// first evaluation does not wait for Entra. A cached token is reused until it expires; within five + /// minutes of expiry it is refreshed in the background. Throws when no token can be acquired. /// /// The agent identity and tenant. /// Resolves the agent identity's Defender token. @@ -212,8 +230,8 @@ public DefenderRtpEvaluationResult Unavailable( /// /// A copy of the context that meets Defender's request validation: target equals the point's - /// field, tool_call and tool_result carry only spec members, content is clamped, the - /// timestamp is UTC, and loosely filled optional fields are repaired or dropped. + /// field, tool_call and tool_result carry only spec members, every content string is + /// clamped, the timestamp is UTC, and loosely filled optional fields are repaired or dropped. /// private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) { @@ -340,9 +358,44 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) } } + ClampOtherContent(hook, maxCharacters); return hook; } + /// + /// Applies to the content + /// does not already clamp with the point's field: message content, tool descriptions and schemas, + /// extensions, and any member a host adds. Envelope fields (ids, names, roles, timestamps) are not + /// truncated, so the request still validates and correlates. + /// + private static void ClampOtherContent(JsonObject hook, int maxCharacters) + { + ClampMember(hook, "extensions", maxCharacters); + foreach (var message in (hook["messages"] as JsonArray ?? new JsonArray()).OfType()) + { + ClampMember(message, "content", maxCharacters); + } + + foreach (var tool in (hook["tools"] as JsonArray ?? new JsonArray()).OfType()) + { + ClampMember(tool, "description", maxCharacters); + ClampMember(tool, "schema", maxCharacters); + } + + foreach (var key in hook.Select(property => property.Key).Where(key => !KnownMembers.Contains(key)).ToList()) + { + ClampMember(hook, key, maxCharacters); + } + } + + private static void ClampMember(JsonObject owner, string key, int maxCharacters) + { + if (owner[key] is { } value) + { + owner[key] = Clamp(value, maxCharacters); + } + } + /// /// Optional fields a host may fill loosely but Defender validates strictly (a 400 would leave the /// call unverified): extension namespaces, model.id, tool declarations, messages, actor. @@ -467,15 +520,24 @@ private static JsonObject ToArguments(JsonNode? args, int maxCharacters) // ---- transport ----------------------------------------------------------------------- + /// Posts the context; is the evaluation's one deadline. private async Task PostAsync( JsonObject hook, string point, string? sessionId, string accessToken, long started, + CancellationToken deadline, CancellationToken cancellationToken) { var correlationId = _idFactory().ToString(); + + // Options is mutable: never send the token or content to an endpoint that is not HTTPS. + if (!DefenderRtpOptions.IsHttpsUrl(Options.Endpoint)) + { + return Failure(point, correlationId, sessionId, "endpoint is not an absolute https URL", null, started); + } + using var request = new HttpRequestMessage(HttpMethod.Post, Options.Endpoint) { Content = new StringContent(hook.ToJsonString(), Encoding.UTF8, "application/json"), @@ -483,21 +545,20 @@ private async Task PostAsync( request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); request.Headers.TryAddWithoutValidation(CorrelationIdHeader, correlationId); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(Options.Timeout); - HttpResponseMessage response; try { - response = await _httpClient.SendAsync(request, timeout.Token).ConfigureAwait(false); + response = await _httpClient.SendAsync(request, deadline).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return Failure(point, correlationId, sessionId, "request timeout", null, started); } - catch (HttpRequestException) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { - return Failure(point, correlationId, sessionId, "request failed", null, started); + // Any transport failure, including resilience handlers' exceptions (for example a broken + // circuit), is no verdict and follows the fail mode. + return Failure(point, correlationId, sessionId, TransportError("request failed", ex), null, started); } using (response) @@ -506,15 +567,15 @@ private async Task PostAsync( string body; try { - body = await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false); + body = await response.Content.ReadAsStringAsync(deadline).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return Failure(point, correlationId, sessionId, "request timeout", status, started); } - catch (HttpRequestException) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { - return Failure(point, correlationId, sessionId, "response body could not be read", status, started); + return Failure(point, correlationId, sessionId, TransportError("response body could not be read", ex), status, started); } if (!response.IsSuccessStatusCode) @@ -582,6 +643,10 @@ private DefenderRtpEvaluationResult Failure( : null, }; + /// A transport error: the exception type, never its message, which can echo request content. + private static string TransportError(string error, Exception exception) => + exception is HttpRequestException ? error : $"{error} ({exception.GetType().Name})"; + private static DefenderRtpVerdict? ParseVerdict(JsonNode? payload) { if (payload is not JsonObject verdict || ReadString(verdict["decision"]) is not ("allow" or "deny" or "transform")) @@ -673,36 +738,78 @@ private async Task GetAccessTokenAsync( { var scope = Options.AuthenticationScope; var key = string.Join(":", agent.TenantId, agent.AgentId, scope); - if (_tokens.TryGetValue(key, out var cached) && _timeProvider.GetUtcNow() < cached.ExpiresAt - TokenRefreshSkew) + var now = _timeProvider.GetUtcNow(); + if (_tokens.TryGetValue(key, out var cached) && now < cached.ExpiresAt) { + if (now >= cached.ExpiresAt - TokenRefreshSkew) + { + // Refresh early in the background and keep using the cached token until it actually + // expires, as MSAL and Azure.Identity do; a failed refresh is retried by a later call. + _ = StartOrJoinAcquisition(key, agent, tokenResolver, scope).ContinueWith( + static task => { _ = task.Exception; }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + return cached.Token; } - // One acquisition per agent, tenant and scope; it is bounded by the configured timeout and - // not tied to any single caller's cancellation. - var acquisition = _inFlightTokens.GetOrAdd( - key, - _ => new Lazy>(() => AcquireTokenAsync(key, agent, tokenResolver, scope))); + return await StartOrJoinAcquisition(key, agent, tokenResolver, scope).WaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// One acquisition per agent, tenant and scope, shared by concurrent callers. It is bounded by the + /// configured timeout rather than by any caller's cancellation, and it leaves the in-flight map as it + /// completes, so a cancelled caller cannot strand it and a failure is never handed to a later caller. + /// + private Task StartOrJoinAcquisition( + string key, + DefenderRtpAgentContext agent, + DefenderRtpTokenResolver tokenResolver, + string scope) + { + while (true) + { + if (_inFlightTokens.TryGetValue(key, out var inFlight)) + { + return inFlight.Value; + } + + Lazy>? acquisition = null; + acquisition = new Lazy>(() => AcquireTokenAsync(key, acquisition!, agent, tokenResolver, scope)); + if (_inFlightTokens.TryAdd(key, acquisition)) + { + return acquisition.Value; + } + } + } + + private async Task AcquireTokenAsync( + string key, + Lazy> acquisition, + DefenderRtpAgentContext agent, + DefenderRtpTokenResolver tokenResolver, + string scope) + { try { - return await acquisition.Value.WaitAsync(cancellationToken).ConfigureAwait(false); + return await ResolveAndCacheTokenAsync(key, agent, tokenResolver, scope).ConfigureAwait(false); } finally { - if (acquisition.IsValueCreated && acquisition.Value.IsCompleted) - { - _inFlightTokens.TryRemove(new KeyValuePair>>(key, acquisition)); - } + // Runs before the acquisition's task completes, so no caller joins a finished or failed one. + _inFlightTokens.TryRemove(new KeyValuePair>>(key, acquisition)); } } - private async Task AcquireTokenAsync( + private async Task ResolveAndCacheTokenAsync( string key, DefenderRtpAgentContext agent, DefenderRtpTokenResolver tokenResolver, string scope) { - using var timeout = new CancellationTokenSource(Options.Timeout); + using var timeout = new CancellationTokenSource(Options.Timeout, _timeProvider); var token = await tokenResolver(agent.AgentId, agent.TenantId, new[] { scope }, timeout.Token).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token)) { diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index 5fa10b83..7d38dc7b 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -21,13 +21,18 @@ public sealed class DefenderRtpOptions /// Whether Defender real-time protection is enabled (ENABLE_A365_DEFENDER_RTP). public bool Enabled { get; set; } - /// The prevention endpoint (A365_DEFENDER_RTP_ENDPOINT). Required when enabled. + /// + /// The prevention endpoint (A365_DEFENDER_RTP_ENDPOINT), an absolute HTTPS URL. Required when enabled. + /// public Uri? Endpoint { get; set; } /// The token scope (A365_DEFENDER_RTP_AUTHENTICATION_SCOPE); defaults to the Defender API. public string AuthenticationScope { get; set; } = DefaultAuthenticationScope; - /// Per-call timeout for token acquisition and evaluation (A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS). + /// + /// The deadline for one evaluation, shared by token acquisition and the request + /// (A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS). When it passes, the result follows . + /// public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10); /// @@ -36,7 +41,12 @@ public sealed class DefenderRtpOptions /// public bool FailClosed { get; set; } - /// Maximum characters per string value sent to Defender (A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS). + /// + /// Maximum characters per content string sent to Defender (A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS): + /// input and output content, tool arguments and results, message content, tool descriptions and + /// schemas, extensions, and any member a host adds. Envelope fields such as ids, names and roles are + /// not truncated. + /// public int MaxContentCharacters { get; set; } = 20000; /// Reads the options from the process environment. @@ -70,9 +80,9 @@ public static DefenderRtpOptions FromEnvironment(Func getVariab if (Read("A365_DEFENDER_RTP_ENDPOINT") is { } endpoint) { - options.Endpoint = Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) + options.Endpoint = Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && IsHttpsUrl(uri) ? uri - : throw new InvalidOperationException("A365_DEFENDER_RTP_ENDPOINT must be an absolute URL."); + : throw new InvalidOperationException("A365_DEFENDER_RTP_ENDPOINT must be an absolute HTTPS URL."); } if (Read("A365_DEFENDER_RTP_AUTHENTICATION_SCOPE") is { } scope) @@ -102,6 +112,12 @@ internal void Validate() "Defender RTP is enabled but no endpoint is configured. Set A365_DEFENDER_RTP_ENDPOINT or DefenderRtpOptions.Endpoint."); } + if (Endpoint != null && !IsHttpsUrl(Endpoint)) + { + throw new InvalidOperationException( + "The Defender RTP endpoint must be an absolute HTTPS URL (A365_DEFENDER_RTP_ENDPOINT or DefenderRtpOptions.Endpoint)."); + } + if (Timeout <= TimeSpan.Zero) { throw new InvalidOperationException("DefenderRtpOptions.Timeout must be positive."); @@ -118,6 +134,12 @@ internal void Validate() } } + /// Whether the URL is absolute and uses HTTPS, so tokens and content never travel in clear text. + /// The URL to check. + /// True for an absolute https URL. + internal static bool IsHttpsUrl(Uri? url) => + url is { IsAbsoluteUri: true } && string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + private static int ParsePositive(string value, string name) => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0 ? parsed diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolver.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolver.cs index a06b6f88..c79255a3 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolver.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolver.cs @@ -13,7 +13,8 @@ namespace Microsoft.Agents.A365.Tooling.Protection.Defender /// /// Use the same authority as Observability S2S export: the blueprint credential obtains the agent /// identity's assertion (FMI), and the agent identity exchanges it for the requested scope. The - /// client caches the returned token per agent, tenant and scope until shortly before it expires. + /// client caches the returned token per agent, tenant and scope until it expires, and refreshes it in the + /// background within five minutes of expiry. /// /// The agent identity (application) id; the token's appid. /// The agent's tenant; the token's tid must equal the context tenant.id. diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs index 0ff254f7..2bc10820 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs @@ -6,6 +6,7 @@ namespace Microsoft.Agents.A365.Tooling.Protection.Defender using System; using System.Collections.Generic; using System.Net.Http; + using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -27,8 +28,11 @@ public static class DefenderRtpTokenResolvers /// (MSAL connections implement it). /// /// The HTTP client for the token endpoint; a pooled client is recommended. - /// The Entra authority; defaults to https://login.microsoftonline.com. + /// + /// The Entra authority, an absolute HTTPS URL; defaults to https://login.microsoftonline.com. + /// /// A resolver for . + /// The authority is not an absolute HTTPS URL. public static DefenderRtpTokenResolver FromAgenticConnection( IAgenticTokenProvider connection, HttpClient? httpClient = null, @@ -39,8 +43,14 @@ public static DefenderRtpTokenResolver FromAgenticConnection( throw new ArgumentNullException(nameof(connection)); } + // The client assertion and the token must never travel in clear text. + if (!Uri.TryCreate(authority, UriKind.Absolute, out var authorityUrl) || !DefenderRtpOptions.IsHttpsUrl(authorityUrl)) + { + throw new ArgumentException("The authority must be an absolute HTTPS URL.", nameof(authority)); + } + var http = httpClient ?? new HttpClient(); - var baseAuthority = authority.TrimEnd('/'); + var baseAuthority = authorityUrl.AbsoluteUri.TrimEnd('/'); return async (agentId, tenantId, scopes, cancellationToken) => { var assertion = await connection.GetAgenticApplicationTokenAsync(tenantId, agentId, cancellationToken).ConfigureAwait(false); @@ -69,7 +79,16 @@ public static DefenderRtpTokenResolver FromAgenticConnection( throw new InvalidOperationException($"The Defender token request failed with HTTP {(int)response.StatusCode}."); } - var payload = JsonNode.Parse(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + JsonNode? payload; + try + { + payload = JsonNode.Parse(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + } + catch (JsonException) + { + throw new InvalidOperationException("The Defender token response was not valid JSON."); + } + return payload?["access_token"] is JsonValue token && token.TryGetValue(out var accessToken) ? accessToken : throw new InvalidOperationException("The Defender token response had no access_token."); diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index dd302b36..4cf35cbb 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -20,14 +20,26 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// /// An agent-hooks interceptor for Microsoft Defender for AI real-time protection. Each context the - /// host emits at input, pre_tool_call, post_tool_call or output is sent to - /// Defender as-is (keeping its session, sequence and tool call ids) and Defender's verdict decides: - /// deny blocks the action. Other points are allowed without a call. + /// host emits at input, pre_tool_call, post_tool_call or output is evaluated + /// by Defender, and Defender's verdict decides: deny blocks the action. Other points are allowed + /// without a call. /// /// + /// + /// Defender receives a copy of the context fitted to its request validation (spec version, UTC + /// timestamp, target, spec-only tool members, repaired optional fields and clamped content). The + /// copy keeps the context's session, sequence and tool call ids; the host's context is not modified. + /// + /// /// When no verdict is obtained (transport, authentication or validation failure), the verdict follows /// : allow with a defender:unverified warning, or deny /// with reason runtime_error:defender_unverified, which is never reported as a detection. + /// + /// + /// Register it on an emitter whose per-interceptor timeout is longer than + /// , such as one from ; + /// a shorter timeout turns a slow evaluation into a fail-closed agent-hooks host error. + /// /// public sealed class A365DefenderInterceptor : IInterceptor { @@ -140,8 +152,9 @@ public static class A365AgentHooks /// profile (an action proceeds only when every interceptor allows it). /// /// - /// Per-interceptor timeout; defaults to the Defender timeout plus two seconds, so the client's own - /// timeout and fail mode apply first. + /// Per-interceptor timeout; defaults to the Defender timeout plus two seconds. The client evaluates + /// within one deadline, , that covers token acquisition and the + /// request, so its fail mode applies before this timeout turns into an agent-hooks host error. /// /// The Defender options whose timeout sets the default. /// The configured emitter. diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 5e65a235..d27569b4 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -6,8 +6,10 @@ control contract (AGENT-HOOKS-0.1), using the .NET package `A365DefenderInterceptor` is an agent-hooks interceptor for Microsoft Defender for AI. Each context the host emits at the four points Defender evaluates is sent to the prevention endpoint -(`POST .../v1/protection/evaluate`) as-is, keeping its session, sequence and tool call ids. Defender's verdict -decides: +(`POST .../v1/protection/evaluate`) as a fitted copy: the client clones the context and fits it to Defender's +request validation (spec version, UTC timestamp, `target`, spec-only tool members, repaired optional fields +and clamped content), keeping its session, sequence and tool call ids. The host's context is not modified. +Defender's verdict decides: | agent-hooks point | When | On `deny` | |---|---|---| @@ -23,11 +25,20 @@ Other points (`agent_startup`, model calls, `agent_shutdown`) are allowed withou Calls carry the **agent identity's own app-only token** in the agent's tenant, for the Defender API (`api://86a21212-634e-4553-b3d6-e477e4c9d9ec`, role `RealtimeProtection.Evaluate.All`). This is the same authority as Observability S2S export: `DefenderRtpTokenResolvers.FromAgenticConnection` asks the agent's -connection (`IAgenticTokenProvider`) for the agent identity's assertion and exchanges it. `a365 setup all` grants -the role to the blueprint as an inheritable permission, so every agent identity under it inherits it -([microsoft/Agent365-devTools#485](https://github.com/microsoft/Agent365-devTools/pull/485)). Defender also -requires the agent's tenant to be onboarded to Microsoft Defender for AI; otherwise it answers `403`, which -follows the fail mode. +connection (`IAgenticTokenProvider`) for the agent identity's assertion and exchanges it. + +The agent blueprint needs `RealtimeProtection.Evaluate.All` as an application permission with admin consent. +[microsoft/Agent365-devTools#485](https://github.com/microsoft/Agent365-devTools/pull/485) adds it to +`a365 setup all`; until that ships, grant it manually (Global Administrator): + +1. In the Entra portal, go to **App registrations** and select your blueprint app. +2. Go to **API permissions** > **Add a permission** > **APIs my organization uses** and search for + `86a21212-634e-4553-b3d6-e477e4c9d9ec`. +3. Select **Application permissions** > `RealtimeProtection.Evaluate.All` > **Add permissions**. +4. Click **Grant admin consent** and confirm. + +Defender also requires the agent's tenant to be onboarded to Microsoft Defender for AI. Without the permission +or the onboarding, Defender answers `403`, which follows the fail mode. ## Usage @@ -64,16 +75,24 @@ if (!record.Proceeds) { /* blocked: record.Verdict.Message */ } Agents built on Microsoft Agent Framework can register the same interceptor with `Microsoft.Agents.AI.AgentHooks`, which mediates model and tool calls. +**Interceptor timeout.** The client evaluates within one deadline, `DefenderRtpOptions.Timeout` (default 10 s), +that covers token acquisition and the request, and applies the fail mode when it passes. The emitter's +per-interceptor timeout must be longer: when it fires first, agent-hooks records a fail-closed host error even +if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` sets it to +`DefenderRtpOptions.Timeout` plus two seconds. If you register the interceptor on another emitter (for example +`new InterceptionEmitter()`, whose default is 5 s, or through Agent Framework), set its interceptor timeout above +`DefenderRtpOptions.Timeout`, or lower `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS`. + ## Configuration | Variable | Meaning | |---|---| | `ENABLE_A365_DEFENDER_RTP` | `true` to call Defender | -| `A365_DEFENDER_RTP_ENDPOINT` | the prevention endpoint, `https:///v1/protection/evaluate` | +| `A365_DEFENDER_RTP_ENDPOINT` | the prevention endpoint, an absolute HTTPS URL: `https:///v1/protection/evaluate` | | `A365_DEFENDER_RTP_FAIL_MODE` | `closed` blocks when no verdict is obtained; default is open | -| `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | per-call timeout (default 10000) | +| `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | the deadline for one evaluation, including token acquisition (default 10000) | | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | overrides the Defender API scope | -| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | clamps each string sent (default 20000) | +| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | clamps each content string sent; ids, names and roles are not truncated (default 20000) | Every call sends a unique `x-ms-correlation-id`, returned as `DefenderRtpEvaluationResult.CorrelationId`; Defender logs each evaluation under it. A `400` reports the failed validation rule in `Error`. From 00b783a36dcdbfc6bfb734631d04aa646cabe694 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 13:24:43 +0100 Subject: [PATCH 03/16] Keep the Defender verdict when the evaluation callback throws The onEvaluated callback is for logging and telemetry. An exception it threw escaped InterceptAsync, and agent-hooks turned it into a fail-closed host error even in fail-open mode. It is now caught, logged to an optional ILogger, and never changes the verdict, matching the Node.js and Python SDKs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../A365DefenderInterceptorTests.cs | 28 ++++++++++++- .../AgentHooks/A365DefenderInterceptor.cs | 42 +++++++++++++++++-- src/Tooling/Extensions/AgentHooks/README.md | 3 ++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index 5df4dab2..3da3b0eb 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -97,6 +97,29 @@ public async Task AllowsWithAWarningWhenFailOpenDefenderIsUnavailable() && warning.Message != null && warning.Message.Contains("not authorized for real-time protection")); } + [Theory] + [InlineData("allow", true)] + [InlineData("deny", false)] + public async Task KeepsTheVerdictWhenTheEvaluationCallbackThrows(string decision, bool proceeds) + { + var harness = new Harness( + _ => Json(new { decision, reason = "prevention_blocked" }), + onEvaluated: _ => throw new InvalidOperationException("telemetry is unavailable")); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-8"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + record.Proceeds.Should().Be(proceeds); + if (proceeds) + { + record.Verdict.Decision.Should().Be(Decision.Allow); + } + else + { + record.Verdict.Reason.Should().Be("defender:block:prevention_blocked", "a failing callback is not a host error"); + } + } + [Fact] public async Task AppliesTheFailModeBeforeTheInterceptorTimeout() { @@ -193,7 +216,8 @@ public Harness( Func respond, bool failClosed = false, string agentId = AgentId, - bool resolveNothing = false) + bool resolveNothing = false, + Action? onEvaluated = null) { var handler = new FakeEndpoint(respond, Bodies, CorrelationIds); var options = new DefenderRtpOptions { Enabled = true, Endpoint = new Uri(Endpoint), FailClosed = failClosed }; @@ -204,7 +228,7 @@ public Harness( .AddA365Defender(new A365DefenderInterceptor( client, _ => resolveNothing ? null : new A365DefenderCall(agent, tokens), - Evaluations.Add)); + onEvaluated ?? Evaluations.Add)); } public InterceptionEmitter Emitter { get; } diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 4cf35cbb..901468c0 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -11,6 +11,7 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AgentHooks using System.Threading.Tasks; using global::AgentHooks; using Microsoft.Agents.A365.Tooling.Protection.Defender; + using Microsoft.Extensions.Logging; /// The agent identity and credentials for the Defender call of one emitted context. /// The agent identity and turn; fills context fields the host did not set. @@ -38,7 +39,8 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// /// Register it on an emitter whose per-interceptor timeout is longer than /// , such as one from ; - /// a shorter timeout turns a slow evaluation into a fail-closed agent-hooks host error. + /// a shorter timeout turns a slow evaluation into a fail-closed agent-hooks host error. The evaluation + /// callback never changes the verdict: an exception it throws is logged and ignored. /// /// public sealed class A365DefenderInterceptor : IInterceptor @@ -51,6 +53,7 @@ public sealed class A365DefenderInterceptor : IInterceptor private readonly DefenderRtpClient _client; private readonly Func _resolveCall; private readonly Action? _onEvaluated; + private readonly ILogger? _logger; /// Initializes a new instance of the class. /// The Defender client. @@ -58,15 +61,21 @@ public sealed class A365DefenderInterceptor : IInterceptor /// Returns the agent identity and token resolver for a context, for example from the current turn; /// null allows the context without a call. /// - /// Receives each evaluation, for logging and telemetry (for example the correlation id). + /// + /// Receives each evaluation, for logging and telemetry (for example the correlation id). It never changes + /// the verdict: an exception it throws is logged to , when given, and ignored. + /// + /// Receives failures of . public A365DefenderInterceptor( DefenderRtpClient client, Func resolveCall, - Action? onEvaluated = null) + Action? onEvaluated = null, + ILogger? logger = null) { _client = client ?? throw new ArgumentNullException(nameof(client)); _resolveCall = resolveCall ?? throw new ArgumentNullException(nameof(resolveCall)); _onEvaluated = onEvaluated; + _logger = logger; } /// @@ -94,10 +103,35 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio return new Verdict(Decision.Allow); } - _onEvaluated?.Invoke(result); + NotifyEvaluated(result); return ToVerdict(result); } + /// + /// Hands the evaluation to the host's callback. The callback is for logging and telemetry, so its + /// failure is logged at most and never changes the verdict, in either fail mode. + /// + private void NotifyEvaluated(DefenderRtpEvaluationResult result) + { + if (_onEvaluated == null) + { + return; + } + + try + { + _onEvaluated(result); + } + catch (Exception ex) + { + _logger?.LogWarning( + ex, + "The Defender evaluation callback failed at {InterceptionPoint}; the verdict is unchanged. CorrelationId: {CorrelationId}", + result.InterceptionPoint, + result.CorrelationId); + } + } + /// Maps a Defender evaluation to the agent-hooks verdict the host composes. /// The Defender evaluation. /// The agent-hooks verdict. diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index d27569b4..6c0d4936 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -72,6 +72,9 @@ var record = await emitter.EmitUncheckedAsync(builder.Input(userMessage), cancel if (!record.Proceeds) { /* blocked: record.Verdict.Message */ } ``` +The evaluation callback is for logging and telemetry and never changes the verdict: an exception it throws is +logged (to the optional `ILogger` passed to `A365DefenderInterceptor`) and ignored. + Agents built on Microsoft Agent Framework can register the same interceptor with `Microsoft.Agents.AI.AgentHooks`, which mediates model and tool calls. From d53b63b76efb13da5bb99a26afd18aaa5ca53b71 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 13:36:32 +0100 Subject: [PATCH 04/16] Align Defender RTP behavior with the Node.js and Python SDKs - The interceptor resolves the per-call identity only when Defender RTP is enabled and Defender evaluates the point, and a failure to resolve it follows the fail mode instead of becoming a fail-closed host error. - tenant.id is always the agent's tenant, which the token is acquired for. - Truncated content stays within MaxContentCharacters, marker included. - trace is an envelope member and is not clamped. - A shared HttpClient replaces the undisposed fallback client. - README: the blueprint-level manual grant of RealtimeProtection.Evaluate.All (inheritable by the blueprint's agent identities) until microsoft/Agent365-devTools#485 ships; the usage snippet imports Microsoft.Agents.Authentication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../Protection/DefenderRtpClientTests.cs | 47 ++++++++-- .../DefenderRtpTokenResolversTests.cs | 21 +++-- .../A365DefenderInterceptorTests.cs | 86 ++++++++++++++++++- .../Protection/Defender/DefenderRtpClient.cs | 40 ++++++--- .../Defender/DefenderRtpTokenResolvers.cs | 6 +- .../AgentHooks/A365DefenderInterceptor.cs | 23 +++-- src/Tooling/Extensions/AgentHooks/README.md | 34 +++++--- 7 files changed, 202 insertions(+), 55 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 6d9e775a..6c076b6a 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -190,23 +190,50 @@ public async Task ClampsEveryContentStringButNotTheEnvelope() context["messages"] = new JsonArray(new JsonObject { ["role"] = "assistant", ["content"] = "previous reply" }); context["tools"] = new JsonArray(new JsonObject { ["name"] = "search_web", ["description"] = "Searches the web." }); context["extensions"] = new JsonObject { ["custom"] = new JsonObject { ["note"] = "long extension text" } }; + context["trace"] = new JsonObject { ["trace_id"] = "4bf92f3577b34da6a3ce929d0e0e4736", ["span_id"] = "00f067aa0ba902b7" }; context["metadata"] = "host-added value"; await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); - body["input"]!["content"]!.GetValue().Should().Be("abcd...[truncated 4 chars]"); - body["messages"]![0]!["content"]!.GetValue().Should().Be("prev...[truncated 10 chars]"); - body["tools"]![0]!["description"]!.GetValue().Should().Be("Sear...[truncated 13 chars]"); - body["extensions"]!["custom"]!["note"]!.GetValue().Should().Be("long...[truncated 15 chars]"); - body["metadata"]!.GetValue().Should().Be("host...[truncated 12 chars]"); + body["input"]!["content"]!.GetValue().Should().Be("abcd", "a limit too small for the marker cuts without one"); + body["messages"]![0]!["content"]!.GetValue().Should().Be("prev"); + body["tools"]![0]!["description"]!.GetValue().Should().Be("Sear"); + body["extensions"]!["custom"]!["note"]!.GetValue().Should().Be("long"); + body["metadata"]!.GetValue().Should().Be("host"); body["messages"]![0]!["role"]!.GetValue().Should().Be("assistant"); body["tools"]![0]!["name"]!.GetValue().Should().Be("search_web"); + body["trace"]!.ToJsonString().Should().Be("""{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}"""); body["agent"]!["name"]!.GetValue().Should().Be("SampleAgent"); body["session"]!["id"]!.GetValue().Should().Be("conversation:activity"); } + [Fact] + public async Task KeepsTruncatedContentWithinTheLimit() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 40 }); + + await client.EvaluateHookContextAsync(InputContext(new string('a', 100)), Agent, tokens.Resolve); + + var content = handler.Calls.Single().Body["input"]!["content"]!.GetValue(); + content.Should().Be(new string('a', 16) + "...[truncated 84 chars]"); + content.Length.Should().BeLessThanOrEqualTo(40); + } + + [Fact] + public async Task AlwaysSendsTheAgentTenant() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" })); + var context = InputContext("hello"); + context["tenant"] = new JsonObject { ["id"] = "cccccccc-0000-4000-8000-000000000003", ["name"] = "Contoso" }; + + await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + handler.Calls.Single().Body["tenant"]!.ToJsonString().Should().Be($$"""{"id":"{{TenantId}}","name":"Contoso"}""", + "the token is acquired for the agent's tenant, and Defender requires the two to match"); + } + [Fact] public async Task ClampsToolContentButNotTheToolIdentity() { @@ -225,8 +252,8 @@ public async Task ClampsToolContentButNotTheToolIdentity() var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); - body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{"query":"Seat...[truncated 12 chars]"}}"""); - body["tool_result"]!["value"]!.GetValue().Should().Be("thre...[truncated 9 chars]"); + body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{"query":"Seat"}}"""); + body["tool_result"]!["value"]!.GetValue().Should().Be("thre"); JsonNode.DeepEquals(body["target"], body["tool_result"]!["value"]).Should().BeTrue(); } @@ -490,7 +517,8 @@ public async Task DoesNotReuseAnAbandonedAcquisitionAfterItsTokenExpires() { var clock = new ManualClock(); var handler = new RecordingHandler(_ => Json(new { decision = "allow" })); - var client = new DefenderRtpClient(Options(), new HttpClient(handler), timeProvider: clock); + using var httpClient = new HttpClient(handler); + var client = new DefenderRtpClient(Options(), httpClient, timeProvider: clock); var tokens = new TokenSource(() => clock.Now); var release = new TaskCompletionSource(); DefenderRtpTokenResolver resolver = async (agentId, tenantId, scopes, cancellationToken) => @@ -525,7 +553,8 @@ public async Task KeepsTheCachedTokenUntilItExpiresWhenAnEarlyRefreshFails() { var clock = new ManualClock(); var handler = new RecordingHandler(_ => Json(new { decision = "allow" })); - var client = new DefenderRtpClient(Options(), new HttpClient(handler), timeProvider: clock); + using var httpClient = new HttpClient(handler); + var client = new DefenderRtpClient(Options(), httpClient, timeProvider: clock); var tokens = new TokenSource(() => clock.Now); await client.EvaluateHookContextAsync(InputContext("one"), Agent, tokens.Resolve); var cached = tokens.Token; diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs index 3807f1d8..2f87af85 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs @@ -24,7 +24,8 @@ public async Task ExchangesTheAgentIdentityAssertionForTheDefenderToken() { var connection = Connection(Assertion); var endpoint = new TokenEndpoint(_ => Json("""{"token_type":"Bearer","expires_in":3599,"access_token":"defender-token"}""")); - var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(connection.Object, new HttpClient(endpoint)); + using var httpClient = new HttpClient(endpoint); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(connection.Object, httpClient); var token = await resolver(AgentId, TenantId, Scopes, CancellationToken.None); @@ -47,7 +48,8 @@ public async Task ExchangesTheAgentIdentityAssertionForTheDefenderToken() public async Task UsesTheGivenAuthority() { var endpoint = new TokenEndpoint(_ => Json("""{"access_token":"defender-token"}""")); - var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(endpoint), "https://login.example.test/"); + using var httpClient = new HttpClient(endpoint); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, httpClient, "https://login.example.test/"); await resolver(AgentId, TenantId, Scopes, CancellationToken.None); @@ -60,7 +62,8 @@ public async Task UsesTheGivenAuthority() [InlineData("")] public void RejectsAnAuthorityThatIsNotAbsoluteHttps(string authority) { - var act = () => DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(), authority); + using var httpClient = new HttpClient(); + var act = () => DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, httpClient, authority); act.Should().Throw().WithParameterName("authority"); } @@ -71,7 +74,8 @@ public void RejectsAnAuthorityThatIsNotAbsoluteHttps(string authority) public async Task FailsWithoutAnAssertion(string? assertion) { var endpoint = new TokenEndpoint(_ => Json("""{"access_token":"defender-token"}""")); - var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(assertion).Object, new HttpClient(endpoint)); + using var httpClient = new HttpClient(endpoint); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(assertion).Object, httpClient); var act = () => resolver(AgentId, TenantId, Scopes, CancellationToken.None); @@ -85,7 +89,8 @@ public async Task FailsOnAnErrorStatusWithoutEchoingTheResponse() var endpoint = new TokenEndpoint(_ => Json( $$"""{"error":"invalid_client","error_description":"Assertion {{Assertion}} was rejected."}""", HttpStatusCode.Unauthorized)); - var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(endpoint)); + using var httpClient = new HttpClient(endpoint); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, httpClient); var act = () => resolver(AgentId, TenantId, Scopes, CancellationToken.None); @@ -100,7 +105,8 @@ public async Task FailsOnAnErrorStatusWithoutEchoingTheResponse() public async Task FailsOnAMalformedSuccessResponse(string body, string message) { var endpoint = new TokenEndpoint(_ => Json(body)); - var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(endpoint)); + using var httpClient = new HttpClient(endpoint); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, httpClient); var act = () => resolver(AgentId, TenantId, Scopes, CancellationToken.None); @@ -115,7 +121,8 @@ public async Task StopsWhenCancelled() await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return Json("""{"access_token":"defender-token"}"""); }); - var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, new HttpClient(endpoint)); + using var httpClient = new HttpClient(endpoint); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, httpClient); using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); var act = () => resolver(AgentId, TenantId, Scopes, cancellation.Token); diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index 3da3b0eb..b99aa254 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -120,6 +120,68 @@ public async Task KeepsTheVerdictWhenTheEvaluationCallbackThrows(string decision } } + [Fact] + public async Task ResolvesTheCallOnlyForPointsDefenderEvaluatesAndOnlyWhenEnabled() + { + var calls = 0; + DefenderRtpTokenResolver tokens = (_, _, _, _) => Task.FromResult(Token); + A365DefenderCall? Resolve(AgentContext _) + { + calls++; + return new A365DefenderCall(new DefenderRtpAgentContext { AgentId = AgentId, TenantId = TenantId }, tokens); + } + + var enabled = new Harness(_ => Json(new { decision = "allow" }), resolveCall: Resolve); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-9"); + await enabled.Emitter.EmitUncheckedAsync(builder.AgentStartup(new[] { "SearchFlights" }), CancellationToken.None); + calls.Should().Be(0, "agent_startup is not evaluated by Defender"); + await enabled.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + calls.Should().Be(1); + + var disabled = new Harness(_ => Json(new { decision = "deny" }), resolveCall: Resolve, enabled: false); + var record = await disabled.Emitter.EmitUncheckedAsync( + new AgentContextBuilder(AgentId, "agent-framework", "s-10").Input(JsonValue.Create("hello")!), + CancellationToken.None); + + record.Proceeds.Should().BeTrue(); + calls.Should().Be(1, "a disabled client never resolves the call"); + disabled.Bodies.Should().BeEmpty(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenResolvingTheCallFails(bool failClosed) + { + var harness = new Harness( + _ => Json(new { decision = "allow" }), + failClosed: failClosed, + resolveCall: _ => throw new InvalidOperationException("no turn is available")); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-11"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed); + harness.Bodies.Should().BeEmpty(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenTheTokenResolverFails(bool failClosed) + { + var harness = new Harness( + _ => Json(new { decision = "allow" }), + failClosed: failClosed, + tokenResolver: (_, _, _, _) => throw new InvalidOperationException("no credential")); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-12"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed); + harness.Bodies.Should().BeEmpty(); + } + [Fact] public async Task AppliesTheFailModeBeforeTheInterceptorTimeout() { @@ -196,6 +258,19 @@ public async Task AllowsWithoutACallWhenNoIdentityIsResolved() harness.Bodies.Should().BeEmpty(); } + private static void AssertFollowsTheFailMode(InterceptionRecord record, bool failClosed) + { + record.Proceeds.Should().Be(!failClosed); + if (failClosed) + { + record.Verdict.Reason.Should().Be("runtime_error:defender_unverified", "a failure is not a host error"); + } + else + { + record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "defender:unverified"); + } + } + private static string Token { get @@ -217,17 +292,20 @@ public Harness( bool failClosed = false, string agentId = AgentId, bool resolveNothing = false, - Action? onEvaluated = null) + Action? onEvaluated = null, + Func? resolveCall = null, + DefenderRtpTokenResolver? tokenResolver = null, + bool enabled = true) { var handler = new FakeEndpoint(respond, Bodies, CorrelationIds); - var options = new DefenderRtpOptions { Enabled = true, Endpoint = new Uri(Endpoint), FailClosed = failClosed }; + var options = new DefenderRtpOptions { Enabled = enabled, Endpoint = new Uri(Endpoint), FailClosed = failClosed }; var client = new DefenderRtpClient(options, new HttpClient(handler)); - DefenderRtpTokenResolver tokens = (_, _, _, _) => Task.FromResult(Token); + DefenderRtpTokenResolver tokens = tokenResolver ?? ((_, _, _, _) => Task.FromResult(Token)); var agent = new DefenderRtpAgentContext { AgentId = agentId, TenantId = TenantId, UserId = "user-object-id" }; Emitter = A365AgentHooks.CreateProtectionEmitter(defender: options) .AddA365Defender(new A365DefenderInterceptor( client, - _ => resolveNothing ? null : new A365DefenderCall(agent, tokens), + resolveCall ?? (_ => resolveNothing ? null : new A365DefenderCall(agent, tokens)), onEvaluated ?? Evaluations.Add)); } diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index f1957ee9..8071c3da 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -50,9 +50,12 @@ public sealed class DefenderRtpClient private static readonly HashSet KnownMembers = new(StringComparer.Ordinal) { "spec", "interception_point", "timestamp", "sequence", "request_id", "session", "agent", "tenant", "actor", "model", - "input", "output", "target", "tool_call", "tool_result", "messages", "tools", "extensions", + "trace", "input", "output", "target", "tool_call", "tool_result", "messages", "tools", "extensions", }; + // Used when the caller passes no HttpClient; pooled connections are recycled so DNS changes are picked up. + internal static readonly HttpClient SharedHttpClient = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }); + private readonly HttpClient _httpClient; private readonly Func _idFactory; private readonly TimeProvider _timeProvider; @@ -62,7 +65,7 @@ public sealed class DefenderRtpClient /// Initializes a new instance of the class. /// The Defender configuration, for example . - /// The HTTP client; a pooled client is recommended. + /// The HTTP client, for example from IHttpClientFactory; defaults to a shared client. /// Creates correlation ids (tests). /// The clock (tests). public DefenderRtpClient( @@ -73,7 +76,7 @@ public DefenderRtpClient( { Options = options ?? throw new ArgumentNullException(nameof(options)); options.Validate(); - _httpClient = httpClient ?? new HttpClient(); + _httpClient = httpClient ?? SharedHttpClient; _idFactory = idFactory ?? Guid.NewGuid; _timeProvider = timeProvider ?? TimeProvider.System; } @@ -267,12 +270,11 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) hook["agent"] = preparedAgent; - if (string.IsNullOrEmpty(ReadString(hook["tenant"]?["id"]))) - { - var tenant = hook["tenant"] as JsonObject ?? new JsonObject(); - tenant["id"] = agent.TenantId; - hook["tenant"] = tenant; - } + // Defender requires tenant.id to equal the token's tid, and the token is always acquired for the + // agent's tenant, so a host-supplied value is never trusted over it. + var tenant = hook["tenant"] as JsonObject ?? new JsonObject(); + tenant["id"] = agent.TenantId; + hook["tenant"] = tenant; if (hook["actor"] == null && !string.IsNullOrEmpty(agent.UserId)) { @@ -937,10 +939,22 @@ private static string SanitizeFramework(string? framework) } } - private static string Truncate(string value, int maxCharacters) => - value.Length <= maxCharacters - ? value - : string.Concat(value.AsSpan(0, maxCharacters), $"...[truncated {value.Length - maxCharacters} chars]"); + private static string Truncate(string value, int maxCharacters) + { + if (value.Length <= maxCharacters) + { + return value; + } + + // The marker counts toward the limit; it is sized for the most digits it can need, so the result + // never exceeds maxCharacters. A limit too small to hold it cuts without a marker. + var kept = maxCharacters - TruncationMarker(value.Length).Length; + return kept > 0 + ? string.Concat(value.AsSpan(0, kept), TruncationMarker(value.Length - kept)) + : value.Substring(0, maxCharacters); + } + + private static string TruncationMarker(int dropped) => $"...[truncated {dropped} chars]"; private static string? ReadString(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : null; diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs index 2bc10820..f2624bd0 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs @@ -27,7 +27,9 @@ public static class DefenderRtpTokenResolvers /// The agent's connection, for example connections.GetDefaultConnection() cast to /// (MSAL connections implement it). /// - /// The HTTP client for the token endpoint; a pooled client is recommended. + /// + /// The HTTP client for the token endpoint, for example from IHttpClientFactory; defaults to a shared client. + /// /// /// The Entra authority, an absolute HTTPS URL; defaults to https://login.microsoftonline.com. /// @@ -49,7 +51,7 @@ public static DefenderRtpTokenResolver FromAgenticConnection( throw new ArgumentException("The authority must be an absolute HTTPS URL.", nameof(authority)); } - var http = httpClient ?? new HttpClient(); + var http = httpClient ?? DefenderRtpClient.SharedHttpClient; var baseAuthority = authorityUrl.AbsoluteUri.TrimEnd('/'); return async (agentId, tenantId, scopes, cancellationToken) => { diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 901468c0..868b55f5 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -32,9 +32,9 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// copy keeps the context's session, sequence and tool call ids; the host's context is not modified. /// /// - /// When no verdict is obtained (transport, authentication or validation failure), the verdict follows - /// : allow with a defender:unverified warning, or deny - /// with reason runtime_error:defender_unverified, which is never reported as a detection. + /// When no verdict is obtained (identity resolution, transport, authentication or validation failure), the + /// verdict follows : allow with a defender:unverified warning, + /// or deny with reason runtime_error:defender_unverified, which is never reported as a detection. /// /// /// Register it on an emitter whose per-interceptor timeout is longer than @@ -59,7 +59,8 @@ public sealed class A365DefenderInterceptor : IInterceptor /// The Defender client. /// /// Returns the agent identity and token resolver for a context, for example from the current turn; - /// null allows the context without a call. + /// null allows the context without a call. It is invoked only when Defender RTP is enabled and Defender + /// evaluates the context's point, and an exception it throws follows the fail mode. /// /// /// Receives each evaluation, for logging and telemetry (for example the correlation id). It never changes @@ -81,8 +82,8 @@ public A365DefenderInterceptor( /// public async ValueTask InterceptAsync(AgentContext context, CancellationToken cancellationToken) { - var call = _resolveCall(context); - if (call == null) + var point = InterceptionPointExtensions.ToWireName(context.InterceptionPoint); + if (!_client.Options.Enabled || !DefenderRtpClient.IsEvaluatedInterceptionPoint(point)) { return new Verdict(Decision.Allow); } @@ -90,12 +91,18 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio DefenderRtpEvaluationResult? result; try { + var call = _resolveCall(context); + if (call == null) + { + return new Verdict(Decision.Allow); + } + result = await _client.EvaluateHookContextAsync(context.Json, call.Agent, call.TokenResolver, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { - // An invalid context or identity is never a verdict: it follows the fail mode. - result = _client.Unavailable(InterceptionPointExtensions.ToWireName(context.InterceptionPoint), $"{ex.GetType().Name}: {ex.Message}"); + // A failure to resolve the identity or to evaluate is never a verdict: it follows the fail mode. + result = _client.Unavailable(point, $"{ex.GetType().Name}: {ex.Message}"); } if (result == null) diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 6c0d4936..2817df2e 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -27,18 +27,27 @@ Calls carry the **agent identity's own app-only token** in the agent's tenant, f authority as Observability S2S export: `DefenderRtpTokenResolvers.FromAgenticConnection` asks the agent's connection (`IAgenticTokenProvider`) for the agent identity's assertion and exchanges it. -The agent blueprint needs `RealtimeProtection.Evaluate.All` as an application permission with admin consent. -[microsoft/Agent365-devTools#485](https://github.com/microsoft/Agent365-devTools/pull/485) adds it to -`a365 setup all`; until that ships, grant it manually (Global Administrator): - -1. In the Entra portal, go to **App registrations** and select your blueprint app. -2. Go to **API permissions** > **Add a permission** > **APIs my organization uses** and search for - `86a21212-634e-4553-b3d6-e477e4c9d9ec`. -3. Select **Application permissions** > `RealtimeProtection.Evaluate.All` > **Add permissions**. -4. Click **Grant admin consent** and confirm. - -Defender also requires the agent's tenant to be onboarded to Microsoft Defender for AI. Without the permission -or the onboarding, Defender answers `403`, which follows the fail mode. +Defender accepts only callers whose app-only token carries the application permission +`RealtimeProtection.Evaluate.All` on the Defender API (`86a21212-634e-4553-b3d6-e477e4c9d9ec`). +[microsoft/Agent365-devTools#485](https://github.com/microsoft/Agent365-devTools/pull/485) adds this to +`a365 setup`. Until it ships, a tenant administrator grants it once per agent blueprint, and every agent identity +created from the blueprint inherits it: + +1. If the tenant has no service principal for the Defender API yet, create one: + `az ad sp create --id 86a21212-634e-4553-b3d6-e477e4c9d9ec`. +2. Assign the app role to the blueprint's service principal: + `POST https://graph.microsoft.com/v1.0/servicePrincipals/{blueprint-sp-object-id}/appRoleAssignments` with + `principalId` (the blueprint service principal), `resourceId` (the Defender API service principal) and + `appRoleId` (the id of `RealtimeProtection.Evaluate.All` in that service principal's `appRoles`). Requires + Global Administrator or Privileged Role Administrator. +3. Make it inheritable: + `POST https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprint-object-id}/inheritablePermissions` + with + `{"resourceAppId":"86a21212-634e-4553-b3d6-e477e4c9d9ec","inheritableScopes":{"@odata.type":"#microsoft.graph.allAllowedScopes","kind":"allAllowed"},"inheritableRoles":{"@odata.type":"#microsoft.graph.allAllowedRoles","kind":"allAllowed"}}`. + Requires Agent ID Administrator or Global Administrator. + +The tenant must also be onboarded to Microsoft Defender for AI; otherwise Defender returns `403`, which follows +the fail mode. ## Usage @@ -46,6 +55,7 @@ or the onboarding, Defender answers `403`, which follows the fail mode. using AgentHooks; using Microsoft.Agents.A365.Tooling.Extensions.AgentHooks; using Microsoft.Agents.A365.Tooling.Protection.Defender; +using Microsoft.Agents.Authentication; var defender = new DefenderRtpClient(DefenderRtpOptions.FromEnvironment(), httpClient); var tokens = DefenderRtpTokenResolvers.FromAgenticConnection( From 2eeba20ea852bc7ae7bebaf52659d738e264031e Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 13:46:48 +0100 Subject: [PATCH 05/16] Do not authorize content Defender saw only truncated Content under decision longer than MaxContentCharacters is truncated before it is sent, but stays whole in the host's context, so an allow of the truncated copy does not cover what was cut. The client now tracks truncation of the content under decision (input, tool arguments, tool result, output): a Defender deny or transform of a truncated copy stands, while an allow is reported with Truncated = true and TruncatedContentError and follows the fail mode. The interceptor maps it to the unverified verdicts. The request endpoint is also read once per call, so the HTTPS check and the request always use the same value. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- CHANGELOG.md | 4 +- .../Protection/DefenderRtpClientTests.cs | 90 +++++++++++++++++++ .../A365DefenderInterceptorTests.cs | 46 ++++++++++ .../Protection/Defender/DefenderRtpClient.cs | 90 +++++++++++++++---- .../Defender/DefenderRtpEvaluationResult.cs | 11 ++- .../Protection/Defender/DefenderRtpOptions.cs | 3 +- .../AgentHooks/A365DefenderInterceptor.cs | 12 ++- src/Tooling/Extensions/AgentHooks/README.md | 9 +- 8 files changed, 241 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3086f8c..898ead0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,9 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. `DefenderRtpTokenResolvers.FromAgenticConnection` uses the agent's connection (`IAgenticTokenProvider`), the same authority as Observability S2S export. - Every call sends a unique `x-ms-correlation-id`. Failures follow `A365_DEFENDER_RTP_FAIL_MODE`, and a `400` - reports the failed validation rule. + reports the failed validation rule. Content under decision longer than + `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` is evaluated as a truncated copy: Defender's deny stands, and its + allow follows the fail mode (`DefenderRtpEvaluationResult.Truncated`). - **Microsoft.Agents.A365.Tooling.Extensions.AgentHooks** (new, preview) - `A365DefenderInterceptor`, an agent-hooks interceptor (`ResponsibleAI.AgentHooks` 0.1.0-beta.1) for Defender, and `A365AgentHooks.CreateProtectionEmitter` (`parallel/strictest`). diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 6c076b6a..0d56550e 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -17,6 +17,7 @@ public class DefenderRtpClientTests private const string Endpoint = "https://prevention.example.test/v1/protection/evaluate"; private const string AgentId = "aaaaaaaa-0000-4000-8000-000000000001"; private const string TenantId = "bbbbbbbb-0000-4000-8000-000000000002"; + private const string Payload = "BLOCK_ME"; private static readonly DefenderRtpAgentContext Agent = new() { @@ -318,6 +319,69 @@ public async Task TreatsTransformAsABlock() result.BlockReason.Should().Contain("rewrite"); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DoesNotLetAnAllowOfATruncatedCopyAuthorizeTheContent(bool failClosed) + { + using var httpClient = new HttpClient(DeniesThePayload()); + var client = new DefenderRtpClient(Options(new DefenderRtpOptions { FailClosed = failClosed }), httpClient); + + var result = await client.EvaluateHookContextAsync(InputContext(new string('a', 20000) + Payload), Agent, new TokenSource().Resolve); + + result!.Evaluated.Should().BeTrue(); + result.Truncated.Should().BeTrue(); + result.Verdict!.Decision.Should().Be("allow", "Defender only saw the harmless prefix"); + result.Allowed.Should().Be(!failClosed); + result.Error.Should().Be(DefenderRtpClient.TruncatedContentError); + (result.BlockReason != null).Should().Be(failClosed); + } + + [Fact] + public async Task KeepsADenyOfATruncatedCopy() + { + using var httpClient = new HttpClient(DeniesThePayload()); + var client = new DefenderRtpClient(Options(), httpClient); + + var result = await client.EvaluateHookContextAsync(InputContext(Payload + new string('a', 20000)), Agent, new TokenSource().Resolve); + + result!.Evaluated.Should().BeTrue(); + result.Truncated.Should().BeTrue(); + result.Allowed.Should().BeFalse(); + result.Verdict!.Decision.Should().Be("deny"); + } + + [Fact] + public async Task AllowsContentWithinTheLimitNormally() + { + using var httpClient = new HttpClient(DeniesThePayload()); + var client = new DefenderRtpClient(Options(new DefenderRtpOptions { FailClosed = true }), httpClient); + + var result = await client.EvaluateHookContextAsync(InputContext(new string('a', 20000)), Agent, new TokenSource().Resolve); + + result!.Evaluated.Should().BeTrue(); + result.Truncated.Should().BeFalse(); + result.Allowed.Should().BeTrue(); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task TracksTruncationOfTheContentUnderDecisionOnly() + { + var (client, _, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 100 }); + var longText = new string('a', 200); + var input = InputContext("short"); + input["messages"] = new JsonArray(new JsonObject { ["role"] = "user", ["content"] = longText }); + + var inputResult = await client.EvaluateHookContextAsync(input, Agent, tokens.Resolve); + var preToolCall = await client.EvaluateHookContextAsync(ToolContext("pre_tool_call", longText, null), Agent, tokens.Resolve); + var postToolCall = await client.EvaluateHookContextAsync(ToolContext("post_tool_call", longText, "short"), Agent, tokens.Resolve); + + inputResult!.Truncated.Should().BeFalse("only the message history was cut"); + preToolCall!.Truncated.Should().BeTrue("the arguments under decision were cut"); + postToolCall!.Truncated.Should().BeFalse("the tool result under decision was not cut"); + } + // ─── failures ──────────────────────────────────────────────────────────── [Theory] @@ -640,6 +704,32 @@ private static DefenderRtpOptions Options(DefenderRtpOptions? overrides = null) return options; } + /// A fake Defender that denies only content containing . + private static RecordingHandler DeniesThePayload() => new(async (request, cancellationToken) => + (await request.Content!.ReadAsStringAsync(cancellationToken)).Contains(Payload, StringComparison.Ordinal) + ? Json(new { decision = "deny", reason = "prevention_blocked", message = "Blocked payload." }) + : Json(new { decision = "allow" })); + + private static JsonObject ToolContext(string point, string args, string? result) + { + var context = new JsonObject + { + ["spec"] = "agent-hooks/0.1", + ["interception_point"] = point, + ["timestamp"] = "2026-10-07T10:00:00.000Z", + ["sequence"] = 5L, + ["agent"] = new JsonObject { ["id"] = AgentId, ["framework"] = "agent365" }, + ["session"] = new JsonObject { ["id"] = "s-tools" }, + ["tool_call"] = new JsonObject { ["id"] = "call_7", ["name"] = "FetchPage", ["args"] = new JsonObject { ["query"] = args } }, + }; + if (result != null) + { + context["tool_result"] = new JsonObject { ["value"] = result, ["is_error"] = false }; + } + + return context; + } + private static (DefenderRtpClient Client, RecordingHandler Handler, TokenSource Tokens) Create( Func respond, DefenderRtpOptions? options = null) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index b99aa254..8ac6cad9 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -20,6 +20,7 @@ public class A365DefenderInterceptorTests private const string Endpoint = "https://prevention.example.test/v1/protection/evaluate"; private const string AgentId = "aaaaaaaa-0000-4000-8000-000000000001"; private const string TenantId = "bbbbbbbb-0000-4000-8000-000000000002"; + private const string Payload = "BLOCK_ME"; [Fact] public async Task ForwardsTheEmittedContextAndAllows() @@ -182,6 +183,45 @@ public async Task FollowsTheFailModeWhenTheTokenResolverFails(bool failClosed) harness.Bodies.Should().BeEmpty(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenDefenderAllowsATruncatedCopy(bool failClosed) + { + var harness = new Harness(DeniesThePayload, failClosed: failClosed); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-13"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create(new string('a', 20000) + Payload)!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed); + record.Verdict.Warnings.Should().Contain(warning => warning.Reason == "defender:unverified" && warning.Message == DefenderRtpClient.TruncatedContentError); + } + + [Fact] + public async Task BlocksWhenDefenderDeniesATruncatedCopy() + { + var harness = new Harness(DeniesThePayload); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-14"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create(Payload + new string('a', 20000))!), CancellationToken.None); + + record.Proceeds.Should().BeFalse(); + record.Verdict.Reason.Should().Be("defender:block:prevention_blocked"); + } + + [Fact] + public async Task AllowsContentWithinTheLimit() + { + var harness = new Harness(DeniesThePayload, failClosed: true); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-15"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create(new string('a', 20000))!), CancellationToken.None); + + record.Proceeds.Should().BeTrue(); + record.Verdict.Decision.Should().Be(Decision.Allow); + (record.Verdict.Warnings ?? Array.Empty()).Should().NotContain(warning => warning.Reason == "defender:unverified"); + } + [Fact] public async Task AppliesTheFailModeBeforeTheInterceptorTimeout() { @@ -258,6 +298,12 @@ public async Task AllowsWithoutACallWhenNoIdentityIsResolved() harness.Bodies.Should().BeEmpty(); } + /// A fake Defender that denies only content containing . + private static HttpResponseMessage DeniesThePayload(JsonObject body) => + body.ToJsonString().Contains(Payload, StringComparison.Ordinal) + ? Json(new { decision = "deny", reason = "prevention_blocked", message = "Blocked payload." }) + : Json(new { decision = "allow" }); + private static void AssertFollowsTheFailMode(InterceptionRecord record, bool failClosed) { record.Proceeds.Should().Be(!failClosed); diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index 8071c3da..28700d73 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -35,6 +35,13 @@ public sealed class DefenderRtpClient /// The header Defender logs each evaluation under. public const string CorrelationIdHeader = "x-ms-correlation-id"; + /// + /// The of a result whose content under decision was longer + /// than and that Defender allowed: Defender evaluated a + /// truncated copy, so the result follows . + /// + public const string TruncatedContentError = "content exceeded MaxContentCharacters; Defender evaluated a truncated copy"; + private const string DefaultFramework = "agent365"; private const string A365Extension = "a365"; private const int MaxErrorDetailCharacters = 200; @@ -93,6 +100,12 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => /// /// Evaluates an agent-hooks context with Defender. The context is not modified. /// + /// + /// When the content under decision is longer than , + /// Defender evaluates a truncated copy: its deny stands, but an allow does not cover the content that was + /// cut, so the result is marked and follows + /// . + /// /// The agent-hooks/0.1 context emitted by the host. /// The agent identity and turn; fills fields the context does not set. /// Resolves the agent identity's Defender token. @@ -132,7 +145,7 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => RequireString(agent.AgentId, nameof(agent.AgentId)); RequireString(agent.TenantId, nameof(agent.TenantId)); - var hook = Prepare(context, agent); + var hook = Prepare(context, agent, out var truncated); var sessionId = ReadString(hook["session"]?["id"]); var started = _timeProvider.GetTimestamp(); @@ -162,7 +175,7 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => return Unavailable(point!, tokenError, sessionId, null, _timeProvider.GetElapsedTime(started)); } - return await PostAsync(hook, point!, sessionId, token!, started, deadline.Token, cancellationToken).ConfigureAwait(false); + return await PostAsync(hook, point!, sessionId, token!, truncated, started, deadline.Token, cancellationToken).ConfigureAwait(false); } /// @@ -235,11 +248,14 @@ public DefenderRtpEvaluationResult Unavailable( /// A copy of the context that meets Defender's request validation: target equals the point's /// field, tool_call and tool_result carry only spec members, every content string is /// clamped, the timestamp is UTC, and loosely filled optional fields are repaired or dropped. + /// is set when the content under decision at the point (the field + /// target mirrors) was cut, so Defender cannot see all of what the verdict would authorize. /// - private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) + private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, out bool truncated) { var hook = (JsonObject)context.DeepClone(); var maxCharacters = Options.MaxContentCharacters; + var decisionTruncated = false; hook["spec"] = AgentHooksSpec; hook["timestamp"] = UtcTimestamp(hook["timestamp"]); @@ -301,7 +317,7 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) var role = ReadString(input?["role"]); var prepared = new JsonObject { - ["content"] = Clamp(input?["content"], maxCharacters) ?? JsonValue.Create(string.Empty), + ["content"] = Clamp(input?["content"], maxCharacters, ref decisionTruncated) ?? JsonValue.Create(string.Empty), ["role"] = role is "system" or "external" ? role : "user", }; hook["input"] = prepared; @@ -313,7 +329,7 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) { var prepared = new JsonObject { - ["content"] = Clamp((hook["output"] as JsonObject)?["content"], maxCharacters) ?? JsonValue.Create(string.Empty), + ["content"] = Clamp((hook["output"] as JsonObject)?["content"], maxCharacters, ref decisionTruncated) ?? JsonValue.Create(string.Empty), }; hook["output"] = prepared; hook["target"] = prepared.DeepClone(); @@ -326,7 +342,9 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) var toolCall = hook["tool_call"] as JsonObject; var toolName = ReadString(toolCall?["name"]); RequireString(toolName, "tool_call.name"); - var args = ToArguments(toolCall?["args"], maxCharacters); + var isPreToolCall = ReadString(hook["interception_point"]) == "pre_tool_call"; + var argsTruncated = false; + var args = ToArguments(toolCall?["args"], maxCharacters, ref argsTruncated); hook["tool_call"] = new JsonObject { ["id"] = FirstNonEmpty(ReadString(toolCall?["id"])) ?? GeneratedToolCallId(), @@ -334,14 +352,15 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) ["args"] = args, }; - if (ReadString(hook["interception_point"]) == "pre_tool_call") + if (isPreToolCall) { + decisionTruncated = argsTruncated; hook["target"] = args.DeepClone(); } else { var toolResult = hook["tool_result"] as JsonObject; - var value = Clamp(toolResult?["value"], maxCharacters); + var value = Clamp(toolResult?["value"], maxCharacters, ref decisionTruncated); var isError = toolResult?["is_error"] is JsonValue flag && flag.TryGetValue(out var failed) && failed; hook["tool_result"] = new JsonObject { @@ -361,6 +380,7 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent) } ClampOtherContent(hook, maxCharacters); + truncated = decisionTruncated; return hook; } @@ -514,33 +534,40 @@ private static JsonObject ToolFromExtensions(JsonObject hook, string toolName) return declaration; } - private static JsonObject ToArguments(JsonNode? args, int maxCharacters) + private static JsonObject ToArguments(JsonNode? args, int maxCharacters, ref bool truncated) { - var clamped = Clamp(args ?? new JsonObject(), maxCharacters); + var clamped = Clamp(args ?? new JsonObject(), maxCharacters, ref truncated); return clamped as JsonObject ?? new JsonObject { ["input"] = clamped }; } // ---- transport ----------------------------------------------------------------------- - /// Posts the context; is the evaluation's one deadline. + /// + /// Posts the context; is the evaluation's one deadline, and + /// says whether the content under decision was cut to fit + /// . + /// private async Task PostAsync( JsonObject hook, string point, string? sessionId, string accessToken, + bool truncated, long started, CancellationToken deadline, CancellationToken cancellationToken) { var correlationId = _idFactory().ToString(); - // Options is mutable: never send the token or content to an endpoint that is not HTTPS. - if (!DefenderRtpOptions.IsHttpsUrl(Options.Endpoint)) + // Options is mutable: read the endpoint once, and never send the token or content to one that is + // not HTTPS. + var endpoint = Options.Endpoint; + if (!DefenderRtpOptions.IsHttpsUrl(endpoint)) { return Failure(point, correlationId, sessionId, "endpoint is not an absolute https URL", null, started); } - using var request = new HttpRequestMessage(HttpMethod.Post, Options.Endpoint) + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { Content = new StringContent(hook.ToJsonString(), Encoding.UTF8, "application/json"), }; @@ -603,10 +630,33 @@ private async Task PostAsync( } var allowed = verdict.Decision == "allow"; + if (truncated && allowed) + { + // Defender saw only a truncated copy, so its allow does not cover the content that was cut and + // follows the fail mode instead. A deny, or a transform (applied here as a block), stands. + return new DefenderRtpEvaluationResult + { + Allowed = !Options.FailClosed, + Evaluated = true, + Truncated = true, + InterceptionPoint = point, + CorrelationId = correlationId, + SessionId = sessionId, + Verdict = verdict, + HttpStatus = status, + Error = TruncatedContentError, + Latency = _timeProvider.GetElapsedTime(started), + BlockReason = Options.FailClosed + ? "The content is longer than Defender evaluates and this agent is configured to fail closed." + : null, + }; + } + return new DefenderRtpEvaluationResult { Allowed = allowed, Evaluated = true, + Truncated = truncated, InterceptionPoint = point, CorrelationId = correlationId, SessionId = sessionId, @@ -911,18 +961,26 @@ private static string SanitizeFramework(string? framework) } private static JsonNode? Clamp(JsonNode? node, int maxCharacters) + { + var truncated = false; + return Clamp(node, maxCharacters, ref truncated); + } + + /// Clamps every string in the value; is set when any was cut. + private static JsonNode? Clamp(JsonNode? node, int maxCharacters, ref bool truncated) { switch (node) { case null: return null; case JsonValue value when value.TryGetValue(out var text): + truncated |= text.Length > maxCharacters; return JsonValue.Create(Truncate(text, maxCharacters)); case JsonArray array: var items = new JsonArray(); foreach (var item in array) { - items.Add(Clamp(item, maxCharacters)); + items.Add(Clamp(item, maxCharacters, ref truncated)); } return items; @@ -930,7 +988,7 @@ private static string SanitizeFramework(string? framework) var properties = new JsonObject(); foreach (var property in obj) { - properties[property.Key] = Clamp(property.Value, maxCharacters); + properties[property.Key] = Clamp(property.Value, maxCharacters, ref truncated); } return properties; diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs index 76dc6d5e..c58cb840 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs @@ -35,7 +35,8 @@ public sealed class DefenderRtpVerdict /// /// The outcome of one Defender evaluation. is false when no verdict was - /// obtained; then follows . + /// obtained; then follows , as it does when + /// Defender allowed a copy. /// public sealed class DefenderRtpEvaluationResult { @@ -45,6 +46,14 @@ public sealed class DefenderRtpEvaluationResult /// Whether Defender returned a verdict. public bool Evaluated { get; init; } + /// + /// Whether the content under decision was longer than , + /// so Defender evaluated a truncated copy. Its deny stands; an allow does not cover the content that was cut, + /// so follows and is + /// . + /// + public bool Truncated { get; init; } + /// The agent-hooks interception point that was evaluated. public string InterceptionPoint { get; init; } = string.Empty; diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index 7d38dc7b..49b4d316 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -45,7 +45,8 @@ public sealed class DefenderRtpOptions /// Maximum characters per content string sent to Defender (A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS): /// input and output content, tool arguments and results, message content, tool descriptions and /// schemas, extensions, and any member a host adds. Envelope fields such as ids, names and roles are - /// not truncated. + /// not truncated. When the content under decision is longer, Defender evaluates a truncated copy and + /// only its deny stands; an allow follows . Raise it for agents with long content. /// public int MaxContentCharacters { get; set; } = 20000; diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 868b55f5..447145d8 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -32,9 +32,10 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// copy keeps the context's session, sequence and tool call ids; the host's context is not modified. /// /// - /// When no verdict is obtained (identity resolution, transport, authentication or validation failure), the - /// verdict follows : allow with a defender:unverified warning, - /// or deny with reason runtime_error:defender_unverified, which is never reported as a detection. + /// When no verdict is obtained (identity resolution, transport, authentication or validation failure), or + /// Defender allowed a copy truncated to , the verdict + /// follows : allow with a defender:unverified warning, or deny + /// with reason runtime_error:defender_unverified, which is never reported as a detection. /// /// /// Register it on an emitter whose per-interceptor timeout is longer than @@ -149,7 +150,10 @@ public static Verdict ToVerdict(DefenderRtpEvaluationResult result) throw new ArgumentNullException(nameof(result)); } - if (result.Evaluated) + // Defender's allow of a truncated copy does not cover the content that was cut, so it is reported + // like a missing verdict; a deny (or transform) of a truncated copy stands. + var authoritative = result.Evaluated && !(result.Truncated && result.Verdict?.Decision == "allow"); + if (authoritative) { var labels = result.Verdict?.ResultLabels is { Count: > 0 } resultLabels ? resultLabels : null; if (result.Allowed) diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 2817df2e..e74590a9 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -105,7 +105,14 @@ if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` | `A365_DEFENDER_RTP_FAIL_MODE` | `closed` blocks when no verdict is obtained; default is open | | `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | the deadline for one evaluation, including token acquisition (default 10000) | | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | overrides the Defender API scope | -| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | clamps each content string sent; ids, names and roles are not truncated (default 20000) | +| `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | clamps each content string sent; ids, names and roles are not truncated (default 20000). See **Content size** | + +**Content size.** Content longer than `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` is truncated before it is sent, and +the agent's own context is not modified. When the content under decision at a point (the input, the tool call's +arguments, the tool result or the reply) was truncated, Defender has not seen all of it: its deny still blocks, +but its allow does not cover the rest, so the result follows the fail mode. Fail open allows with a +`defender:unverified` warning; fail closed denies with `runtime_error:defender_unverified`. Raise the limit for +agents that handle long content. Evaluating long content in chunks is a planned follow-up. Every call sends a unique `x-ms-correlation-id`, returned as `DefenderRtpEvaluationResult.CorrelationId`; Defender logs each evaluation under it. A `400` reports the failed validation rule in `Error`. From bb7ff72412f3dcc9c7ab6a3300a803d6f6058e76 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 14:16:15 +0100 Subject: [PATCH 06/16] Fit Defender requests within a total budget and harden parsing - Replace lone surrogates with U+FFFD in every outbound string value and property name, so Defender can parse the request, and never split a surrogate pair when truncating. - Cap the fitted copy at four times MaxContentCharacters of content, built from the host's context without cloning it first: the content under decision first, then tool declarations (the called tool's first), the newest message history, extensions and other members. Content under decision that had to be cut is marked Truncated. - A per-call factory that resolves no agent identity follows the fail mode ("no agent identity was resolved") and reaches onEvaluated. - Check each optional node's shape before reading it (session, model, the a365 extension, transform, diagnostics, token payloads), so a value of another shape never turns an evaluation or a decision into an error. - Read JSON members with TryGetPropertyValue, and filter the callback catch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- CHANGELOG.md | 10 +- .../Protection/DefenderRtpClientTests.cs | 255 +++++++++- .../DefenderRtpTokenResolversTests.cs | 2 + .../A365DefenderInterceptorTests.cs | 11 +- .../Protection/Defender/DefenderRtpClient.cs | 453 ++++++++++++------ .../Protection/Defender/DefenderRtpOptions.cs | 6 +- .../Defender/DefenderRtpTokenResolvers.cs | 2 +- .../AgentHooks/A365DefenderInterceptor.cs | 28 +- src/Tooling/Extensions/AgentHooks/README.md | 15 +- 9 files changed, 581 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 898ead0d..b68bf49e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,12 +83,14 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. `DefenderRtpTokenResolvers.FromAgenticConnection` uses the agent's connection (`IAgenticTokenProvider`), the same authority as Observability S2S export. - Every call sends a unique `x-ms-correlation-id`. Failures follow `A365_DEFENDER_RTP_FAIL_MODE`, and a `400` - reports the failed validation rule. Content under decision longer than - `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` is evaluated as a truncated copy: Defender's deny stands, and its - allow follows the fail mode (`DefenderRtpEvaluationResult.Truncated`). + reports the failed validation rule. Each string is clamped to `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS`, and + the copy carries at most four times that in all: the content under decision first, the oldest history dropped + first. When the content under decision had to be cut, Defender's deny stands and its allow follows the fail + mode (`DefenderRtpEvaluationResult.Truncated`). Lone surrogates are sent as U+FFFD. - **Microsoft.Agents.A365.Tooling.Extensions.AgentHooks** (new, preview) - `A365DefenderInterceptor`, an agent-hooks interceptor (`ResponsibleAI.AgentHooks` 0.1.0-beta.1) for Defender, and - `A365AgentHooks.CreateProtectionEmitter` (`parallel/strictest`). + `A365AgentHooks.CreateProtectionEmitter` (`parallel/strictest`). When no agent identity is resolved, or resolving + it fails, the interceptor follows the fail mode. - **Microsoft.Agents.A365.Tooling** - V1/V2 per-audience token support for MCP servers - `MCPServerConfig` extended with `audience`, `scope`, `publisher`, and `Headers` fields - `IMcpTokenProvider` interface for pluggable OAuth token acquisition diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 0d56550e..af0a0ace 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -183,26 +183,47 @@ public async Task RepairsLooselyFilledOptionalFields() body["actor"]!.ToJsonString().Should().Be("""{"id":"user"}"""); } + [Theory] + [InlineData("\"metadata\"")] + [InlineData("""{ "tool": "metadata" }""")] + [InlineData("[1, 2]")] + public async Task ToleratesOptionalMembersOfAnotherShape(string a365) + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" })); + var context = ToolContext("pre_tool_call", "x", null); + context["model"] = "gpt-4o"; + context["extensions"] = new JsonObject { ["a365"] = JsonNode.Parse(a365) }; + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + result!.Evaluated.Should().BeTrue(); + var body = handler.Calls.Single().Body; + DefenderContract.Errors(body).Should().BeEmpty(); + body.ContainsKey("model").Should().BeFalse(); + body["tools"]!.ToJsonString().Should().Be("""[{"name":"FetchPage"}]"""); + } + [Fact] - public async Task ClampsEveryContentStringButNotTheEnvelope() + public async Task ClampsEachContentStringButNotTheEnvelope() { - var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 4 }); - var context = InputContext("abcdefgh"); - context["messages"] = new JsonArray(new JsonObject { ["role"] = "assistant", ["content"] = "previous reply" }); - context["tools"] = new JsonArray(new JsonObject { ["name"] = "search_web", ["description"] = "Searches the web." }); - context["extensions"] = new JsonObject { ["custom"] = new JsonObject { ["note"] = "long extension text" } }; + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 100 }); + var context = InputContext("hello"); + context["messages"] = new JsonArray(new JsonObject { ["role"] = "assistant", ["content"] = new string('m', 150) }); + context["tools"] = new JsonArray(new JsonObject { ["name"] = "search_web", ["description"] = new string('d', 150) }); + context["extensions"] = new JsonObject { ["custom"] = new JsonObject { ["note"] = new string('e', 150) } }; context["trace"] = new JsonObject { ["trace_id"] = "4bf92f3577b34da6a3ce929d0e0e4736", ["span_id"] = "00f067aa0ba902b7" }; - context["metadata"] = "host-added value"; + context["metadata"] = new string('h', 150); - await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); - body["input"]!["content"]!.GetValue().Should().Be("abcd", "a limit too small for the marker cuts without one"); - body["messages"]![0]!["content"]!.GetValue().Should().Be("prev"); - body["tools"]![0]!["description"]!.GetValue().Should().Be("Sear"); - body["extensions"]!["custom"]!["note"]!.GetValue().Should().Be("long"); - body["metadata"]!.GetValue().Should().Be("host"); + result!.Truncated.Should().BeFalse("the content under decision was not cut"); + body["input"]!["content"]!.GetValue().Should().Be("hello"); + body["tools"]![0]!["description"]!.GetValue().Should().Be(new string('d', 76) + "...[truncated 74 chars]"); + body["messages"]![0]!["content"]!.GetValue().Should().Be(new string('m', 76) + "...[truncated 74 chars]"); + body["extensions"]!["custom"]!["note"]!.GetValue().Should().Be(new string('e', 76) + "...[truncated 74 chars]"); + body["metadata"]!.GetValue().Should().Be(new string('h', 64) + "...[truncated 86 chars]", "it gets what the rest left"); body["messages"]![0]!["role"]!.GetValue().Should().Be("assistant"); body["tools"]![0]!["name"]!.GetValue().Should().Be("search_web"); body["trace"]!.ToJsonString().Should().Be("""{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}"""); @@ -222,6 +243,19 @@ public async Task KeepsTruncatedContentWithinTheLimit() content.Length.Should().BeLessThanOrEqualTo(40); } + [Fact] + public async Task DoesNotSplitASurrogatePairWhenTruncating() + { + var (withMarker, markerHandler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 40 }); + var (withoutMarker, plainHandler, _) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 4 }); + + await withMarker.EvaluateHookContextAsync(InputContext(new string('a', 15) + "\U0001F600" + new string('b', 100)), Agent, tokens.Resolve); + await withoutMarker.EvaluateHookContextAsync(InputContext("abc\U0001F600def"), Agent, tokens.Resolve); + + markerHandler.Calls.Single().Body["input"]!["content"]!.GetValue().Should().Be(new string('a', 15) + "...[truncated 102 chars]"); + plainHandler.Calls.Single().Body["input"]!["content"]!.GetValue().Should().Be("abc"); + } + [Fact] public async Task AlwaysSendsTheAgentTenant() { @@ -238,7 +272,7 @@ public async Task AlwaysSendsTheAgentTenant() [Fact] public async Task ClampsToolContentButNotTheToolIdentity() { - var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 4 }); + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 10 }); var context = JsonNode.Parse($$""" { "spec": "agent-hooks/0.1", "interception_point": "post_tool_call", "timestamp": "2026-10-07T10:00:00.000Z", "sequence": 4, @@ -249,13 +283,106 @@ public async Task ClampsToolContentButNotTheToolIdentity() } """)!.AsObject(); - await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + var body = handler.Calls.Single().Body; + DefenderContract.Errors(body).Should().BeEmpty(); + result!.Truncated.Should().BeTrue("the tool result under decision was cut"); + body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{"query":"Seattle to"}}"""); + body["tool_result"]!["value"]!.GetValue().Should().Be("three resu"); + JsonNode.DeepEquals(body["target"], body["tool_result"]!["value"]).Should().BeTrue(); + } + + [Fact] + public async Task KeepsTheContentUnderDecisionAndDropsTheOldestHistoryFirst() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 100 }); + var context = InputContext(new string('i', 100)); + context["messages"] = new JsonArray(Enumerable.Range(0, 5) + .Select(index => (JsonNode?)new JsonObject { ["role"] = "user", ["content"] = index + new string('m', 99) }) + .ToArray()); + context["extensions"] = new JsonObject { ["custom"] = new JsonObject { ["note"] = "dropped" } }; + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); - body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{"query":"Seat"}}"""); - body["tool_result"]!["value"]!.GetValue().Should().Be("thre"); + result!.Truncated.Should().BeFalse("the content under decision fit"); + body["input"]!["content"]!.GetValue().Should().Be(new string('i', 100)); + body["messages"]!.AsArray().Select(message => message!["content"]!.GetValue()[0]).Should().Equal('3', '4'); + body["extensions"].Should().BeNull("the budget ran out before the extensions"); + } + + [Fact] + public async Task CapsTheTotalSizeOfTheCopy() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 100 }); + var hugeResult = new JsonObject(); + for (var index = 0; index < 1000; index++) + { + hugeResult[$"k{index}"] = new string('x', 100); + } + + var context = ToolContext("post_tool_call", "query", null); + context["tool_result"] = new JsonObject { ["value"] = hugeResult, ["is_error"] = false }; + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + var body = handler.Calls.Single().Body; + DefenderContract.Errors(body).Should().BeEmpty(); + body.ToJsonString().Length.Should().BeLessThan(2000, "the copy carries at most four times MaxContentCharacters of content"); JsonNode.DeepEquals(body["target"], body["tool_result"]!["value"]).Should().BeTrue(); + result!.Truncated.Should().BeTrue(); + result.Allowed.Should().BeTrue("Defender allowed a truncated copy and the client fails open"); + result.Error.Should().Be(DefenderRtpClient.TruncatedContentError); + } + + [Fact] + public async Task KeepsTheCalledToolsDeclarationWhenTheBudgetIsShort() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 20 }); + var context = ToolContext("pre_tool_call", "x", null); + context["tools"] = new JsonArray(Enumerable.Range(0, 4) + .Select(index => (JsonNode?)new JsonObject { ["name"] = $"Other{index}", ["description"] = new string('d', 20) }) + .Append(new JsonObject { ["name"] = "FetchPage", ["description"] = "Reads a page." }) + .ToArray()); + + await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + var body = handler.Calls.Single().Body; + DefenderContract.Errors(body).Should().BeEmpty(); + body["tools"]![0]!.ToJsonString().Should().Be("""{"name":"FetchPage","description":"Reads a page."}"""); + } + + [Fact] + public async Task ReplacesLoneSurrogatesSoDefenderCanParseTheRequest() + { + RecordingHandler? handler = null; + handler = new RecordingHandler(_ => + { + var sent = handler!.Calls[^1].Body; + if (ContainsLoneSurrogate(sent)) + { + return Json(new { title = "Bad Request", detail = "The request is not valid UTF-16." }, HttpStatusCode.BadRequest); + } + + return sent.ToJsonString().Contains(Payload, StringComparison.Ordinal) + ? Json(new { decision = "deny", reason = "prevention_blocked" }) + : Json(new { decision = "allow" }); + }); + using var httpClient = new HttpClient(handler); + var client = new DefenderRtpClient(Options(), httpClient); + var context = InputContext("placeholder"); + context["input"]!["content"] = "\uD800" + Payload; + context["extensions"] = new JsonObject { ["custom"] = new JsonObject { ["key\uDC00"] = "value" } }; + + var result = await client.EvaluateHookContextAsync(context, Agent, new TokenSource().Resolve); + + result!.Evaluated.Should().BeTrue("the request no longer carries a lone surrogate"); + result.Allowed.Should().BeFalse(); + var body = handler.Calls.Single().Body; + body["input"]!["content"]!.GetValue().Should().Be("\uFFFD" + Payload); + body["extensions"]!["custom"]!.AsObject().Select(property => property.Key).Should().Equal("key\uFFFD"); } [Fact] @@ -270,6 +397,19 @@ public async Task RejectsAContextWithoutAnAgentId() await act.Should().ThrowAsync(); } + [Fact] + public async Task RejectsAContextWhoseSessionIsNotAnObject() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" })); + var context = InputContext("hello"); + context["session"] = "s-1"; + + var act = () => client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + await act.Should().ThrowAsync().WithMessage("*session.id*"); + handler.Calls.Should().BeEmpty(); + } + // ─── verdicts ──────────────────────────────────────────────────────────── [Fact] @@ -319,6 +459,21 @@ public async Task TreatsTransformAsABlock() result.BlockReason.Should().Contain("rewrite"); } + [Theory] + [InlineData("deny")] + [InlineData("transform")] + public async Task KeepsTheDecisionWhenTransformHasAnotherShape(string decision) + { + var (client, _, tokens) = Create(_ => Json(new { decision, reason = "prevention_blocked", transform = new[] { "unexpected" } })); + + var result = await client.EvaluateHookContextAsync(InputContext("secret"), Agent, tokens.Resolve); + + result!.Evaluated.Should().BeTrue(); + result.Allowed.Should().BeFalse(); + result.Verdict!.Decision.Should().Be(decision); + result.Verdict.TransformPath.Should().BeNull(); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -418,6 +573,20 @@ public async Task ReportsTheFailedValidationRulesOfA400() result!.Error.Should().Be("http 400: validation: The target field must match input."); } + [Theory] + [InlineData("[1]")] + [InlineData("\"[1]\"")] + public async Task ReportsA400WhoseDiagnosticsHaveAnotherShape(string diagnostics) + { + var error = new JsonObject { ["title"] = "Bad Request", ["diagnostics"] = JsonNode.Parse(diagnostics) }; + var (client, _, tokens) = Create(_ => Json(error, HttpStatusCode.BadRequest)); + + var result = await client.EvaluateHookContextAsync(InputContext("hello"), Agent, tokens.Resolve); + + result!.Evaluated.Should().BeFalse(); + result.Error.Should().Be("http 400: Bad Request"); + } + [Fact] public async Task TreatsASuccessWithoutADecisionAsNoVerdict() { @@ -639,6 +808,17 @@ public async Task KeepsTheCachedTokenUntilItExpiresWhenAnEarlyRefreshFails() handler.Calls.Should().HaveCount(2); } + [Fact] + public async Task UsesATokenWhosePayloadIsNotAnObject() + { + var (client, handler, _) = Create(_ => Json(new { decision = "allow" })); + + var result = await client.EvaluateHookContextAsync(InputContext("hello"), Agent, (_, _, _, _) => Task.FromResult("e30.W10.signature")); + + result!.Evaluated.Should().BeTrue("a token without a readable expiry is still used"); + handler.Calls.Should().ContainSingle(); + } + // ─── options ───────────────────────────────────────────────────────────── [Fact] @@ -710,6 +890,31 @@ private static DefenderRtpOptions Options(DefenderRtpOptions? overrides = null) ? Json(new { decision = "deny", reason = "prevention_blocked", message = "Blocked payload." }) : Json(new { decision = "allow" })); + private static bool ContainsLoneSurrogate(JsonNode? node) => node switch + { + JsonValue value when value.TryGetValue(out var text) => HasLoneSurrogate(text), + JsonArray array => array.Any(ContainsLoneSurrogate), + JsonObject obj => obj.Any(property => HasLoneSurrogate(property.Key) || ContainsLoneSurrogate(property.Value)), + _ => false, + }; + + private static bool HasLoneSurrogate(string text) + { + for (var index = 0; index < text.Length; index++) + { + if (char.IsHighSurrogate(text[index]) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1])) + { + index++; + } + else if (char.IsSurrogate(text[index])) + { + return true; + } + } + + return false; + } + private static JsonObject ToolContext(string point, string args, string? result) { var context = new JsonObject @@ -860,10 +1065,10 @@ public static List Errors(JsonObject context) if (string.IsNullOrEmpty(Text(context["agent"]?["id"]))) errors.Add("agent.id"); if (Text(context["agent"]?["framework"]) is not { } framework || !Framework.IsMatch(framework)) errors.Add("agent.framework"); if (string.IsNullOrEmpty(Text(context["session"]?["id"]))) errors.Add("session.id"); - if (!context.ContainsKey("target")) errors.Add("target"); + if (!context.TryGetPropertyValue("target", out var target)) errors.Add("target"); if (context["extensions"] is JsonObject extensions && extensions.Any(property => !ExtensionKey.IsMatch(property.Key))) errors.Add("extensions"); - if (context.ContainsKey("model") && string.IsNullOrEmpty(Text(context["model"]?["id"]))) errors.Add("model.id"); - if (context.ContainsKey("tools") && (context["tools"] is not JsonArray tools + if (context.TryGetPropertyValue("model", out var model) && string.IsNullOrEmpty(Text(model?["id"]))) errors.Add("model.id"); + if (context.TryGetPropertyValue("tools", out var declaredTools) && (declaredTools is not JsonArray tools || tools.Any(tool => string.IsNullOrEmpty(Text(tool?["name"])) || (tool!["schema"] != null && tool["schema"] is not JsonObject)))) errors.Add("tools"); if (context["actor"]?["kind"] is { } kind && Text(kind) is not ("human" or "service" or "agent")) errors.Add("actor.kind"); if (context["tool_call"] is JsonObject call && call.Any(property => property.Key is not ("id" or "name" or "args" or "content_hash"))) errors.Add("tool_call members"); @@ -873,10 +1078,10 @@ public static List Errors(JsonObject context) { case "input": if (Text(context["input"]?["role"]) is not ("user" or "system" or "external")) errors.Add("input.role"); - if (!JsonNode.DeepEquals(context["target"], context["input"])) errors.Add("target != input"); + if (!JsonNode.DeepEquals(target, context["input"])) errors.Add("target != input"); break; case "output": - if (!JsonNode.DeepEquals(context["target"], context["output"])) errors.Add("target != output"); + if (!JsonNode.DeepEquals(target, context["output"])) errors.Add("target != output"); break; case "pre_tool_call": case "post_tool_call": @@ -884,13 +1089,13 @@ public static List Errors(JsonObject context) if (context["tool_call"]?["args"] is not JsonObject) errors.Add("tool_call.args"); if (Text(context["interception_point"]) == "pre_tool_call") { - if (!JsonNode.DeepEquals(context["target"], context["tool_call"]?["args"])) errors.Add("target != tool_call.args"); + if (!JsonNode.DeepEquals(target, context["tool_call"]?["args"])) errors.Add("target != tool_call.args"); } else { if (context["tool_result"]?["is_error"] is not JsonValue flag || !flag.TryGetValue(out _)) errors.Add("tool_result.is_error"); - if (context["tool_result"] is not JsonObject value || !value.ContainsKey("value")) errors.Add("tool_result.value"); - else if (!JsonNode.DeepEquals(context["target"], value["value"])) errors.Add("target != tool_result.value"); + if (context["tool_result"] is not JsonObject toolResult || !toolResult.TryGetPropertyValue("value", out var resultValue)) errors.Add("tool_result.value"); + else if (!JsonNode.DeepEquals(target, resultValue)) errors.Add("target != tool_result.value"); } break; diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs index 2f87af85..2c9f6580 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs @@ -102,6 +102,8 @@ public async Task FailsOnAnErrorStatusWithoutEchoingTheResponse() [InlineData("not json", "*not valid JSON*")] [InlineData("""{"token_type":"Bearer"}""", "*no access_token*")] [InlineData("""{"access_token":42}""", "*no access_token*")] + [InlineData("[]", "*no access_token*")] + [InlineData("\"defender-token\"", "*no access_token*")] public async Task FailsOnAMalformedSuccessResponse(string body, string message) { var endpoint = new TokenEndpoint(_ => Json(body)); diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index 8ac6cad9..6d552b4c 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -286,16 +286,19 @@ public async Task DoesNotCallDefenderForPointsItDoesNotEvaluate() harness.Bodies.Should().BeEmpty(); } - [Fact] - public async Task AllowsWithoutACallWhenNoIdentityIsResolved() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FollowsTheFailModeWhenNoIdentityIsResolved(bool failClosed) { - var harness = new Harness(_ => Json(new { decision = "deny" }), resolveNothing: true); + var harness = new Harness(_ => Json(new { decision = "allow" }), failClosed: failClosed, resolveNothing: true); var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-6"); var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); - record.Proceeds.Should().BeTrue(); + AssertFollowsTheFailMode(record, failClosed); harness.Bodies.Should().BeEmpty(); + harness.Evaluations.Should().ContainSingle().Which.Error.Should().Be("no agent identity was resolved"); } /// A fake Defender that denies only content containing . diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index 28700d73..02e38730 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -47,6 +47,9 @@ public sealed class DefenderRtpClient private const int MaxErrorDetailCharacters = 200; private const int MaxCachedTokens = 100; private const int MaxTrackedSessions = 1000; + + // A fitted copy carries at most this many times MaxContentCharacters of content in all. + private const int ContentBudgetFactor = 4; private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromMinutes(5); private static readonly HashSet EvaluatedPoints = new(StringComparer.Ordinal) { "input", "pre_tool_call", "post_tool_call", "output" }; private static readonly HashSet ActorKinds = new(StringComparer.Ordinal) { "human", "service", "agent" }; @@ -60,6 +63,12 @@ public sealed class DefenderRtpClient "trace", "input", "output", "target", "tool_call", "tool_result", "messages", "tools", "extensions", }; + // Envelope members: copied and repaired, never treated as content. + private static readonly string[] EnvelopeMembers = + { + "interception_point", "timestamp", "sequence", "request_id", "session", "agent", "tenant", "actor", "model", "trace", + }; + // Used when the caller passes no HttpClient; pooled connections are recycled so DNS changes are picked up. internal static readonly HttpClient SharedHttpClient = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }); @@ -146,7 +155,7 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => RequireString(agent.AgentId, nameof(agent.AgentId)); RequireString(agent.TenantId, nameof(agent.TenantId)); var hook = Prepare(context, agent, out var truncated); - var sessionId = ReadString(hook["session"]?["id"]); + var sessionId = ReadString((hook["session"] as JsonObject)?["id"]); var started = _timeProvider.GetTimestamp(); // One deadline covers token acquisition and the request, so the fail mode applies within @@ -245,24 +254,34 @@ public DefenderRtpEvaluationResult Unavailable( // ---- agent-hooks context ------------------------------------------------------------- /// - /// A copy of the context that meets Defender's request validation: target equals the point's - /// field, tool_call and tool_result carry only spec members, every content string is - /// clamped, the timestamp is UTC, and loosely filled optional fields are repaired or dropped. - /// is set when the content under decision at the point (the field - /// target mirrors) was cut, so Defender cannot see all of what the verdict would authorize. + /// Builds the copy of the context Defender evaluates without cloning the host's content. The envelope is + /// copied and repaired so the request meets Defender's validation; the content under decision at the point + /// (the field target mirrors) is clamped first, and then the tool call's arguments at + /// post_tool_call, tool declarations, the newest message history, extensions and any other member + /// share what remains of times + /// . Lone surrogates are replaced, since Defender cannot + /// parse them. is set when the content under decision was cut, so Defender + /// cannot see all of what its verdict would authorize. /// private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, out bool truncated) { - var hook = (JsonObject)context.DeepClone(); var maxCharacters = Options.MaxContentCharacters; - var decisionTruncated = false; - hook["spec"] = AgentHooksSpec; + var budget = new ContentBudget((int)Math.Min(int.MaxValue, (long)ContentBudgetFactor * maxCharacters)); + var hook = new JsonObject { ["spec"] = AgentHooksSpec }; + foreach (var member in EnvelopeMembers) + { + if (context[member] is { } value) + { + hook[member] = value.DeepClone(); + } + } + hook["timestamp"] = UtcTimestamp(hook["timestamp"]); var agentNode = hook["agent"] as JsonObject; var agentId = FirstNonEmpty(agent.AgentObjectId, ReadString(agentNode?["id"]), agent.AgentId); RequireString(agentId, "agent.id"); - var sessionId = ReadString(hook["session"]?["id"]); + var sessionId = ReadString((hook["session"] as JsonObject)?["id"]); RequireString(sessionId, "session.id"); if (!IsNonNegativeInteger(hook["sequence"])) { @@ -307,17 +326,23 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou hook["model"] = new JsonObject { ["id"] = agent.ModelName }; } - DropInvalidOptionalFields(hook); + FitModelAndActor(hook); - switch (ReadString(hook["interception_point"])) + // The content under decision is sent twice, as the point's field and as target, so it may use half of + // the budget; the rest of the context shares what it leaves. + var point = ReadString(hook["interception_point"]); + var decisionBudget = new ContentBudget(budget.Remaining / 2); + var decisionTruncated = false; + string? toolName = null; + switch (point) { case "input": { - var input = hook["input"] as JsonObject; + var input = context["input"] as JsonObject; var role = ReadString(input?["role"]); var prepared = new JsonObject { - ["content"] = Clamp(input?["content"], maxCharacters, ref decisionTruncated) ?? JsonValue.Create(string.Empty), + ["content"] = Clamp(input?["content"], maxCharacters, decisionBudget, ref decisionTruncated) ?? JsonValue.Create(string.Empty), ["role"] = role is "system" or "external" ? role : "user", }; hook["input"] = prepared; @@ -329,7 +354,7 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou { var prepared = new JsonObject { - ["content"] = Clamp((hook["output"] as JsonObject)?["content"], maxCharacters, ref decisionTruncated) ?? JsonValue.Create(string.Empty), + ["content"] = Clamp((context["output"] as JsonObject)?["content"], maxCharacters, decisionBudget, ref decisionTruncated) ?? JsonValue.Create(string.Empty), }; hook["output"] = prepared; hook["target"] = prepared.DeepClone(); @@ -339,28 +364,25 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou case "pre_tool_call": case "post_tool_call": { - var toolCall = hook["tool_call"] as JsonObject; - var toolName = ReadString(toolCall?["name"]); + var toolCall = context["tool_call"] as JsonObject; + toolName = ReadString(toolCall?["name"]); RequireString(toolName, "tool_call.name"); - var isPreToolCall = ReadString(hook["interception_point"]) == "pre_tool_call"; - var argsTruncated = false; - var args = ToArguments(toolCall?["args"], maxCharacters, ref argsTruncated); - hook["tool_call"] = new JsonObject + var preparedCall = new JsonObject { ["id"] = FirstNonEmpty(ReadString(toolCall?["id"])) ?? GeneratedToolCallId(), ["name"] = toolName, - ["args"] = args, }; - - if (isPreToolCall) + hook["tool_call"] = preparedCall; + if (point == "pre_tool_call") { - decisionTruncated = argsTruncated; + var args = ToArguments(toolCall?["args"], maxCharacters, decisionBudget, ref decisionTruncated); + preparedCall["args"] = args; hook["target"] = args.DeepClone(); } else { - var toolResult = hook["tool_result"] as JsonObject; - var value = Clamp(toolResult?["value"], maxCharacters, ref decisionTruncated); + var toolResult = context["tool_result"] as JsonObject; + var value = Clamp(toolResult?["value"], maxCharacters, decisionBudget, ref decisionTruncated); var isError = toolResult?["is_error"] is JsonValue flag && flag.TryGetValue(out var failed) && failed; hook["tool_result"] = new JsonObject { @@ -370,80 +392,48 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou hook["target"] = value?.DeepClone(); } - if (hook["tools"] is not JsonArray { Count: > 0 }) - { - hook["tools"] = new JsonArray(ToolFromExtensions(hook, toolName!)); - } - break; } } - ClampOtherContent(hook, maxCharacters); - truncated = decisionTruncated; - return hook; - } + budget.Spend(2 * decisionBudget.Used); - /// - /// Applies to the content - /// does not already clamp with the point's field: message content, tool descriptions and schemas, - /// extensions, and any member a host adds. Envelope fields (ids, names, roles, timestamps) are not - /// truncated, so the request still validates and correlates. - /// - private static void ClampOtherContent(JsonObject hook, int maxCharacters) - { - ClampMember(hook, "extensions", maxCharacters); - foreach (var message in (hook["messages"] as JsonArray ?? new JsonArray()).OfType()) + // The rest of the context. What is cut here does not change the authority of the verdict. + var contextCut = false; + if (point == "post_tool_call" && hook["tool_call"] is JsonObject calledTool) { - ClampMember(message, "content", maxCharacters); + calledTool["args"] = ToArguments((context["tool_call"] as JsonObject)?["args"], maxCharacters, budget, ref contextCut); } - foreach (var tool in (hook["tools"] as JsonArray ?? new JsonArray()).OfType()) + AddTools(hook, context, toolName, maxCharacters, budget); + AddMessages(hook, context, maxCharacters, budget); + AddExtensions(hook, context, maxCharacters, budget); + foreach (var property in context) { - ClampMember(tool, "description", maxCharacters); - ClampMember(tool, "schema", maxCharacters); - } + if (budget.Remaining == 0) + { + break; + } - foreach (var key in hook.Select(property => property.Key).Where(key => !KnownMembers.Contains(key)).ToList()) - { - ClampMember(hook, key, maxCharacters); + if (!KnownMembers.Contains(property.Key)) + { + hook[property.Key] = Clamp(property.Value, maxCharacters, budget, ref contextCut); + } } - } - private static void ClampMember(JsonObject owner, string key, int maxCharacters) - { - if (owner[key] is { } value) - { - owner[key] = Clamp(value, maxCharacters); - } + truncated = decisionTruncated; + return (JsonObject)ReplaceLoneSurrogates(hook)!; } /// - /// Optional fields a host may fill loosely but Defender validates strictly (a 400 would leave the - /// call unverified): extension namespaces, model.id, tool declarations, messages, actor. + /// Repairs model and actor, which a host may fill loosely but Defender validates strictly (a + /// 400 would leave the call unverified). /// - private static void DropInvalidOptionalFields(JsonObject hook) + private static void FitModelAndActor(JsonObject hook) { - if (hook["extensions"] is JsonObject extensions) + if (hook.TryGetPropertyValue("model", out var model)) { - foreach (var key in extensions.Select(property => property.Key).Where(key => !ExtensionKeyPattern.IsMatch(key)).ToList()) - { - extensions.Remove(key); - } - - if (extensions.Count == 0) - { - hook.Remove("extensions"); - } - } - else if (hook.ContainsKey("extensions")) - { - hook.Remove("extensions"); - } - - if (hook.ContainsKey("model")) - { - if (ReadString(hook["model"]?["id"]) is { Length: > 0 } modelId) + if (ReadString((model as JsonObject)?["id"]) is { Length: > 0 } modelId) { hook["model"] = new JsonObject { ["id"] = modelId }; } @@ -453,90 +443,156 @@ private static void DropInvalidOptionalFields(JsonObject hook) } } - if (hook.ContainsKey("tools")) + if (hook.TryGetPropertyValue("actor", out var actorNode)) { - var tools = new JsonArray(); - foreach (var tool in (hook["tools"] as JsonArray ?? new JsonArray()).OfType()) + if (actorNode is JsonObject actor) { - if (ReadString(tool["name"]) is not { Length: > 0 } toolName) + var prepared = new JsonObject(); + if (ReadString(actor["id"]) is { Length: > 0 } actorId) { - continue; + prepared["id"] = actorId; } - var declaration = new JsonObject { ["name"] = toolName }; - if (ReadString(tool["description"]) is { } description) + if (ReadString(actor["kind"]) is { } kind && ActorKinds.Contains(kind)) { - declaration["description"] = description; + prepared["kind"] = kind; } - if (tool["schema"] is JsonObject schema) - { - declaration["schema"] = schema.DeepClone(); - } + hook["actor"] = prepared; + } + else + { + hook.Remove("actor"); + } + } + } - tools.Add(declaration); + /// + /// Copies the valid tool declarations (a name, and a string description and object schema when present) + /// within the budget, the called tool's first so a short budget never drops it. At a tool point without + /// declarations, the called tool is declared from the a365 extension. + /// + private static void AddTools(JsonObject hook, JsonObject context, string? toolName, int maxCharacters, ContentBudget budget) + { + var cut = false; + var tools = new JsonArray(); + var declared = (context["tools"] as JsonArray ?? new JsonArray()).OfType() + .OrderBy(tool => toolName != null && ReadString(tool["name"]) == toolName ? 0 : 1); + foreach (var tool in declared) + { + if (budget.Remaining == 0) + { + break; } - if (tools.Count > 0) + if (ReadString(tool["name"]) is not { Length: > 0 } declaredName) { - hook["tools"] = tools; + continue; } - else + + var declaration = new JsonObject { ["name"] = declaredName }; + if (tool["description"] is JsonValue description && ReadString(description) != null) + { + declaration["description"] = Clamp(description, maxCharacters, budget, ref cut); + } + + if (tool["schema"] is JsonObject schema) { - hook.Remove("tools"); + declaration["schema"] = Clamp(schema, maxCharacters, budget, ref cut); } + + tools.Add(declaration); } - if (hook.ContainsKey("messages")) + if (tools.Count == 0 && toolName != null) { - var valid = hook["messages"] is JsonArray messages - && messages.All(message => message is JsonObject item - && ReadString(item["role"]) is { Length: > 0 } - && item.ContainsKey("content")); - if (!valid) + var declaration = new JsonObject { ["name"] = toolName }; + // An extension namespace may hold any JSON value, so each level's shape is checked before it is read. + if ((context["extensions"] as JsonObject)?[A365Extension] is JsonObject a365 + && a365["tool"] is JsonObject calledTool + && calledTool["description"] is JsonValue description + && ReadString(Clamp(description, maxCharacters, budget, ref cut)) is { Length: > 0 } clamped) { - hook.Remove("messages"); + declaration["description"] = clamped; } + + tools.Add(declaration); } - if (hook.ContainsKey("actor")) + if (tools.Count > 0) { - if (hook["actor"] is JsonObject actor) - { - var prepared = new JsonObject(); - if (ReadString(actor["id"]) is { Length: > 0 } actorId) - { - prepared["id"] = actorId; - } + hook["tools"] = tools; + } + } - if (ReadString(actor["kind"]) is { } kind && ActorKinds.Contains(kind)) - { - prepared["kind"] = kind; - } + /// + /// Copies the message history within the budget, newest first, so older messages are dropped before newer + /// ones. A history with a message lacking a role or content is dropped whole, since Defender rejects it. + /// + private static void AddMessages(JsonObject hook, JsonObject context, int maxCharacters, ContentBudget budget) + { + if (context["messages"] is not JsonArray messages + || !messages.All(message => message is JsonObject item + && ReadString(item["role"]) is { Length: > 0 } + && item.ContainsKey("content"))) + { + return; + } - hook["actor"] = prepared; - } - else + var cut = false; + var kept = new List(); + for (var index = messages.Count - 1; index >= 0 && budget.Remaining > 0; index--) + { + var copy = new JsonObject(); + foreach (var property in (JsonObject)messages[index]!) { - hook.Remove("actor"); + copy[property.Key] = property.Key == "role" + ? property.Value?.DeepClone() + : Clamp(property.Value, maxCharacters, budget, ref cut); } + + kept.Add(copy); + } + + if (kept.Count > 0) + { + kept.Reverse(); + hook["messages"] = new JsonArray(kept.ToArray()); } } - private static JsonObject ToolFromExtensions(JsonObject hook, string toolName) + /// Copies the extension namespaces Defender accepts, within the budget. + private static void AddExtensions(JsonObject hook, JsonObject context, int maxCharacters, ContentBudget budget) { - var declaration = new JsonObject { ["name"] = toolName }; - if (ReadString(hook["extensions"]?[A365Extension]?["tool"]?["description"]) is { Length: > 0 } description) + if (context["extensions"] is not JsonObject extensions) { - declaration["description"] = description; + return; } - return declaration; + var cut = false; + var kept = new JsonObject(); + foreach (var property in extensions) + { + if (budget.Remaining == 0) + { + break; + } + + if (ExtensionKeyPattern.IsMatch(property.Key)) + { + kept[property.Key] = Clamp(property.Value, maxCharacters, budget, ref cut); + } + } + + if (kept.Count > 0) + { + hook["extensions"] = kept; + } } - private static JsonObject ToArguments(JsonNode? args, int maxCharacters, ref bool truncated) + private static JsonObject ToArguments(JsonNode? args, int maxCharacters, ContentBudget budget, ref bool truncated) { - var clamped = Clamp(args ?? new JsonObject(), maxCharacters, ref truncated); + var clamped = Clamp(args ?? new JsonObject(), maxCharacters, budget, ref truncated); return clamped as JsonObject ?? new JsonObject { ["input"] = clamped }; } @@ -721,7 +777,8 @@ private static string TransportError(string error, Exception exception) => .Select(Text) .OfType() .ToList(), - TransformPath = Text(verdict["transform"]?["path"]), + // Read only from an object, so unexpected transform metadata never costs Defender its decision. + TransformPath = Text((verdict["transform"] as JsonObject)?["path"]), }; } @@ -772,7 +829,7 @@ private static string ErrorDetail(string body) } } - var messages = (parsed?["validationErrors"] as JsonArray ?? new JsonArray()) + var messages = ((parsed as JsonObject)?["validationErrors"] as JsonArray ?? new JsonArray()) .OfType() .Select(item => ReadString(item["message"])) .Where(message => !string.IsNullOrEmpty(message)) @@ -907,7 +964,7 @@ private async Task ResolveAndCacheTokenAsync( { var payload = parts[1].Replace('-', '+').Replace('_', '/'); payload = payload.PadRight(payload.Length + ((4 - (payload.Length % 4)) % 4), '='); - if (JsonNode.Parse(Convert.FromBase64String(payload))?["exp"] is not JsonValue exp) + if ((JsonNode.Parse(Convert.FromBase64String(payload)) as JsonObject)?["exp"] is not JsonValue exp) { return null; } @@ -960,27 +1017,75 @@ private static string SanitizeFramework(string? framework) return value.Length > 0 ? value : DefaultFramework; } - private static JsonNode? Clamp(JsonNode? node, int maxCharacters) + /// + /// Copies a value with every string clamped to and the whole copy kept within + /// . Each array element and property also costs at least one character, so the copy + /// stays bounded however the value is shaped. is set when anything was cut. + /// + private static JsonNode? Clamp(JsonNode? node, int maxCharacters, ContentBudget budget, ref bool truncated) { - var truncated = false; - return Clamp(node, maxCharacters, ref truncated); + switch (node) + { + case null: + return null; + case JsonValue value when value.TryGetValue(out var text): + var limit = Math.Min(maxCharacters, budget.Remaining); + truncated |= text.Length > limit; + var clamped = Truncate(text, limit); + budget.Spend(clamped.Length); + return JsonValue.Create(clamped); + case JsonArray array: + var items = new JsonArray(); + foreach (var item in array) + { + if (budget.Remaining == 0) + { + truncated = true; + break; + } + + budget.Spend(1); + items.Add(Clamp(item, maxCharacters, budget, ref truncated)); + } + + return items; + case JsonObject obj: + var properties = new JsonObject(); + foreach (var property in obj) + { + if (budget.Remaining < property.Key.Length + 1) + { + truncated = true; + break; + } + + budget.Spend(property.Key.Length + 1); + properties[property.Key] = Clamp(property.Value, maxCharacters, budget, ref truncated); + } + + return properties; + default: + return node.DeepClone(); + } } - /// Clamps every string in the value; is set when any was cut. - private static JsonNode? Clamp(JsonNode? node, int maxCharacters, ref bool truncated) + /// + /// A copy of the value with lone surrogates replaced by U+FFFD in every string and property name: they are + /// not valid UTF-16, so Defender cannot parse a request that carries them. Valid surrogate pairs are kept. + /// + private static JsonNode? ReplaceLoneSurrogates(JsonNode? node) { switch (node) { case null: return null; case JsonValue value when value.TryGetValue(out var text): - truncated |= text.Length > maxCharacters; - return JsonValue.Create(Truncate(text, maxCharacters)); + return JsonValue.Create(ReplaceLoneSurrogates(text)); case JsonArray array: var items = new JsonArray(); foreach (var item in array) { - items.Add(Clamp(item, maxCharacters, ref truncated)); + items.Add(ReplaceLoneSurrogates(item)); } return items; @@ -988,7 +1093,7 @@ private static string SanitizeFramework(string? framework) var properties = new JsonObject(); foreach (var property in obj) { - properties[property.Key] = Clamp(property.Value, maxCharacters, ref truncated); + properties[ReplaceLoneSurrogates(property.Key)] = ReplaceLoneSurrogates(property.Value); } return properties; @@ -997,6 +1102,36 @@ private static string SanitizeFramework(string? framework) } } + private static string ReplaceLoneSurrogates(string text) + { + var index = 0; + while (index < text.Length && !char.IsSurrogate(text[index])) + { + index++; + } + + if (index == text.Length) + { + return text; + } + + var builder = new StringBuilder(text.Length).Append(text, 0, index); + for (; index < text.Length; index++) + { + var character = text[index]; + if (char.IsHighSurrogate(character) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1])) + { + builder.Append(character).Append(text[++index]); + } + else + { + builder.Append(char.IsSurrogate(character) ? '\uFFFD' : character); + } + } + + return builder.ToString(); + } + private static string Truncate(string value, int maxCharacters) { if (value.Length <= maxCharacters) @@ -1007,11 +1142,19 @@ private static string Truncate(string value, int maxCharacters) // The marker counts toward the limit; it is sized for the most digits it can need, so the result // never exceeds maxCharacters. A limit too small to hold it cuts without a marker. var kept = maxCharacters - TruncationMarker(value.Length).Length; - return kept > 0 - ? string.Concat(value.AsSpan(0, kept), TruncationMarker(value.Length - kept)) - : value.Substring(0, maxCharacters); + if (kept <= 0) + { + return value.Substring(0, KeepSurrogatePairs(value, maxCharacters)); + } + + kept = KeepSurrogatePairs(value, kept); + return string.Concat(value.AsSpan(0, kept), TruncationMarker(value.Length - kept)); } + /// Moves a cut back one code unit when it would split a surrogate pair, which cannot be serialized. + private static int KeepSurrogatePairs(string value, int cut) => + cut > 0 && cut < value.Length && char.IsHighSurrogate(value[cut - 1]) && char.IsLowSurrogate(value[cut]) ? cut - 1 : cut; + private static string TruncationMarker(int dropped) => $"...[truncated {dropped} chars]"; private static string? ReadString(JsonNode? node) => @@ -1032,5 +1175,25 @@ private static void RequireString(string? value, string name) } private sealed record CachedToken(string Token, DateTimeOffset ExpiresAt); + + /// The characters of content a fitted copy may still carry. + private sealed class ContentBudget + { + public ContentBudget(int characters) + { + Remaining = Math.Max(0, characters); + } + + public int Remaining { get; private set; } + + public int Used { get; private set; } + + public void Spend(int characters) + { + var spent = Math.Min(Remaining, Math.Max(0, characters)); + Remaining -= spent; + Used += spent; + } + } } } diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index 49b4d316..06514047 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -45,8 +45,10 @@ public sealed class DefenderRtpOptions /// Maximum characters per content string sent to Defender (A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS): /// input and output content, tool arguments and results, message content, tool descriptions and /// schemas, extensions, and any member a host adds. Envelope fields such as ids, names and roles are - /// not truncated. When the content under decision is longer, Defender evaluates a truncated copy and - /// only its deny stands; an allow follows . Raise it for agents with long content. + /// not truncated. The copy as a whole carries at most four times this many characters of content: the + /// content under decision first, then the rest of the context, with the oldest messages dropped first. + /// When the content under decision is cut, Defender evaluates a truncated copy and only its deny stands; an + /// allow follows . Raise it for agents with long content. /// public int MaxContentCharacters { get; set; } = 20000; diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs index f2624bd0..715ee9de 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs @@ -91,7 +91,7 @@ public static DefenderRtpTokenResolver FromAgenticConnection( throw new InvalidOperationException("The Defender token response was not valid JSON."); } - return payload?["access_token"] is JsonValue token && token.TryGetValue(out var accessToken) + return (payload as JsonObject)?["access_token"] is JsonValue token && token.TryGetValue(out var accessToken) ? accessToken : throw new InvalidOperationException("The Defender token response had no access_token."); }; diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 447145d8..42351dbc 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -32,10 +32,10 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// copy keeps the context's session, sequence and tool call ids; the host's context is not modified. /// /// - /// When no verdict is obtained (identity resolution, transport, authentication or validation failure), or - /// Defender allowed a copy truncated to , the verdict - /// follows : allow with a defender:unverified warning, or deny - /// with reason runtime_error:defender_unverified, which is never reported as a detection. + /// When no verdict is obtained (no agent identity, identity resolution, transport, authentication or validation + /// failure), or Defender allowed a copy truncated to , the + /// verdict follows : allow with a defender:unverified warning, + /// or deny with reason runtime_error:defender_unverified, which is never reported as a detection. /// /// /// Register it on an emitter whose per-interceptor timeout is longer than @@ -59,9 +59,10 @@ public sealed class A365DefenderInterceptor : IInterceptor /// Initializes a new instance of the class. /// The Defender client. /// - /// Returns the agent identity and token resolver for a context, for example from the current turn; - /// null allows the context without a call. It is invoked only when Defender RTP is enabled and Defender - /// evaluates the context's point, and an exception it throws follows the fail mode. + /// Returns the agent identity and token resolver for a context, for example from the current turn. It is + /// invoked only when Defender RTP is enabled and Defender evaluates the context's point. When it returns + /// null (no agent identity is available) or throws, the context is not sent and the verdict follows the + /// fail mode. /// /// /// Receives each evaluation, for logging and telemetry (for example the correlation id). It never changes @@ -92,13 +93,12 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio DefenderRtpEvaluationResult? result; try { + // Without an agent identity Defender cannot be called; like any missing verdict, that follows the + // fail mode rather than allowing silently. var call = _resolveCall(context); - if (call == null) - { - return new Verdict(Decision.Allow); - } - - result = await _client.EvaluateHookContextAsync(context.Json, call.Agent, call.TokenResolver, cancellationToken).ConfigureAwait(false); + result = call == null + ? _client.Unavailable(point, "no agent identity was resolved") + : await _client.EvaluateHookContextAsync(context.Json, call.Agent, call.TokenResolver, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { @@ -130,7 +130,7 @@ private void NotifyEvaluated(DefenderRtpEvaluationResult result) { _onEvaluated(result); } - catch (Exception ex) + catch (Exception ex) when (ex is not OutOfMemoryException) { _logger?.LogWarning( ex, diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index e74590a9..55f002d5 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -107,12 +107,15 @@ if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | overrides the Defender API scope | | `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | clamps each content string sent; ids, names and roles are not truncated (default 20000). See **Content size** | -**Content size.** Content longer than `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` is truncated before it is sent, and -the agent's own context is not modified. When the content under decision at a point (the input, the tool call's -arguments, the tool result or the reply) was truncated, Defender has not seen all of it: its deny still blocks, -but its allow does not cover the rest, so the result follows the fail mode. Fail open allows with a -`defender:unverified` warning; fail closed denies with `runtime_error:defender_unverified`. Raise the limit for -agents that handle long content. Evaluating long content in chunks is a planned follow-up. +**Content size.** Each content string longer than `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` (default 20000) is +truncated before it is sent, and the request as a whole carries at most four times that much content: the content +under decision at the point (the input, the tool call's arguments, the tool result or the reply) first, then tool +declarations, the newest message history, extensions and other members, with the oldest messages dropped first. +The agent's own context is not modified. When the content under decision was cut, Defender has not seen all of +it: its deny still blocks, but its allow does not cover the rest, so the result follows the fail mode. Fail open +allows with a `defender:unverified` warning; fail closed denies with `runtime_error:defender_unverified`. Raise the +limit for agents that handle long content. Evaluating long content in chunks is a planned follow-up. Lone UTF-16 +surrogates in any string are replaced with U+FFFD, since Defender cannot parse them. Every call sends a unique `x-ms-correlation-id`, returned as `DefenderRtpEvaluationResult.CorrelationId`; Defender logs each evaluation under it. A `400` reports the failed validation rule in `Error`. From d22be9fbb8500391edf12d395e3575c925842a9c Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 14:38:16 +0100 Subject: [PATCH 07/16] Harden Defender options, token cache and callback isolation - Accept only "open" or "closed" for A365_DEFENDER_RTP_FAIL_MODE, so a typo fails startup instead of quietly selecting fail-open. - Serialize writes to the token cache, so concurrent acquisitions cannot each evict the same entry and push it past its bound. - Decide the interceptor's verdict before the evaluation callback runs, copy result labels into it, and return read-only warning and label lists, so a callback cannot change the verdict. - Make the abandoned-acquisition tests wait for the acquisition itself instead of a fixed delay. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- CHANGELOG.md | 11 ++- docs/design.md | 2 +- .../Protection/DefenderRtpClientTests.cs | 96 ++++++++++++++++++- .../A365DefenderInterceptorTests.cs | 42 ++++++++ .../Protection/Defender/DefenderRtpClient.cs | 44 +++++++-- .../Protection/Defender/DefenderRtpOptions.cs | 28 +++++- .../AgentHooks/A365DefenderInterceptor.cs | 14 ++- src/Tooling/Extensions/AgentHooks/README.md | 6 +- 8 files changed, 216 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b68bf49e..96466922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,11 +82,12 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. `DefenderRtpTokenResolver`, cached per agent and tenant and refreshed in the background before it expires; `DefenderRtpTokenResolvers.FromAgenticConnection` uses the agent's connection (`IAgenticTokenProvider`), the same authority as Observability S2S export. - - Every call sends a unique `x-ms-correlation-id`. Failures follow `A365_DEFENDER_RTP_FAIL_MODE`, and a `400` - reports the failed validation rule. Each string is clamped to `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS`, and - the copy carries at most four times that in all: the content under decision first, the oldest history dropped - first. When the content under decision had to be cut, Defender's deny stands and its allow follows the fail - mode (`DefenderRtpEvaluationResult.Truncated`). Lone surrogates are sent as U+FFFD. + - Every call sends a unique `x-ms-correlation-id`. Failures follow `A365_DEFENDER_RTP_FAIL_MODE` (`open` or + `closed`; any other value is rejected), and a `400` reports the failed validation rule. Each string is clamped + to `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS`, and the copy carries at most four times that in all: the content + under decision first, the oldest history dropped first. When the content under decision had to be cut, + Defender's deny stands and its allow follows the fail mode (`DefenderRtpEvaluationResult.Truncated`). Lone + surrogates are sent as U+FFFD. - **Microsoft.Agents.A365.Tooling.Extensions.AgentHooks** (new, preview) - `A365DefenderInterceptor`, an agent-hooks interceptor (`ResponsibleAI.AgentHooks` 0.1.0-beta.1) for Defender, and `A365AgentHooks.CreateProtectionEmitter` (`parallel/strictest`). When no agent identity is resolved, or resolving diff --git a/docs/design.md b/docs/design.md index 4b5a13f7..063166e7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -339,7 +339,7 @@ else if (EnvironmentUtils.IsDevelopmentEnvironment()) | `SuppressInvokeAgentInput` | Suppress input messages in invoke_agent spans | `false` | | `ENABLE_A365_DEFENDER_RTP` | Enable Microsoft Defender for AI real-time protection (`DefenderRtpOptions.FromEnvironment`) | `false` | | `A365_DEFENDER_RTP_ENDPOINT` | Defender prevention endpoint, `https:///v1/protection/evaluate` | - | -| `A365_DEFENDER_RTP_FAIL_MODE` | `closed` blocks when Defender returns no verdict | open | +| `A365_DEFENDER_RTP_FAIL_MODE` | `open` or `closed` (blocks when Defender returns no verdict); any other value is rejected | open | ## Testing diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index af0a0ace..89d66c5d 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -447,6 +447,24 @@ public async Task AllowsWithWarnings() result.Verdict!.Warnings.Should().Equal(new DefenderRtpWarning("prevention_annotated", "Suspicious but allowed.")); } + [Fact] + public async Task ReturnsAVerdictThatCannotBeChanged() + { + var (client, _, tokens) = Create(_ => Json(new + { + decision = "allow", + warnings = new[] { new { reason = "prevention_annotated", message = "Suspicious but allowed." } }, + result_labels = new[] { "MaliciousContentPropagation" }, + })); + + var result = await client.EvaluateHookContextAsync(InputContext("hello"), Agent, tokens.Resolve); + + var warnings = (IList)result!.Verdict!.Warnings; + var labels = (IList)result.Verdict.ResultLabels; + warnings.Invoking(list => list.Clear()).Should().Throw(); + labels.Invoking(list => list.Clear()).Should().Throw(); + } + [Fact] public async Task TreatsTransformAsABlock() { @@ -736,7 +754,7 @@ public async Task StartsAFreshAcquisitionAfterAnAbandonedOneFails() Func waitForAbandoned = () => abandoned; await waitForAbandoned.Should().ThrowAsync(); release.SetResult(); - await Task.Delay(50); + await WaitForTheAcquisition(client); var result = await client.EvaluateHookContextAsync(InputContext("two"), Agent, resolver); @@ -770,7 +788,7 @@ public async Task DoesNotReuseAnAbandonedAcquisitionAfterItsTokenExpires() Func waitForAbandoned = () => abandoned; await waitForAbandoned.Should().ThrowAsync(); release.SetResult(); - await Task.Delay(50); + await WaitForTheAcquisition(client); var expired = tokens.Token; clock.Now += TimeSpan.FromHours(2); @@ -819,6 +837,30 @@ public async Task UsesATokenWhosePayloadIsNotAnObject() handler.Calls.Should().ContainSingle(); } + [Fact] + public async Task KeepsTheTokenCacheBoundedUnderConcurrentAcquisitions() + { + var (client, _, _) = Create(_ => Json(new { decision = "allow" })); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var evaluations = Enumerable.Range(0, 300).Select(index => + { + var tokens = new TokenSource(); + DefenderRtpTokenResolver resolver = async (agentId, tenantId, scopes, cancellationToken) => + { + await release.Task.ConfigureAwait(false); + return await tokens.Resolve(agentId, tenantId, scopes, cancellationToken).ConfigureAwait(false); + }; + var agent = new DefenderRtpAgentContext { AgentId = $"agent-{index}", TenantId = TenantId }; + return client.EvaluateHookContextAsync(InputContext("hello"), agent, resolver); + }).ToList(); + + release.SetResult(); + var results = await Task.WhenAll(evaluations); + + results.Should().OnlyContain(result => result!.Evaluated); + client.CachedTokenCount.Should().BeLessThanOrEqualTo(100, "the cache holds at most 100 tokens however acquisitions interleave"); + } + // ─── options ───────────────────────────────────────────────────────────── [Fact] @@ -864,6 +906,31 @@ public void RejectsAnEndpointThatIsNotAbsoluteHttps(string endpoint) fromEnvironment.Should().Throw().WithMessage("*absolute HTTPS URL*"); } + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("open", false)] + [InlineData(" Open ", false)] + [InlineData("closed", true)] + [InlineData("CLOSED", true)] + public void ReadsTheFailMode(string? value, bool failClosed) + { + var options = DefenderRtpOptions.FromEnvironment(name => name == "A365_DEFENDER_RTP_FAIL_MODE" ? value : null); + + options.FailClosed.Should().Be(failClosed); + } + + [Theory] + [InlineData("close")] + [InlineData("fail-closed")] + [InlineData("true")] + public void RejectsAnUnknownFailModeRatherThanFailingOpen(string value) + { + var act = () => DefenderRtpOptions.FromEnvironment(name => name == "A365_DEFENDER_RTP_FAIL_MODE" ? value : null); + + act.Should().Throw().WithMessage("*A365_DEFENDER_RTP_FAIL_MODE*"); + } + // ─── helpers ───────────────────────────────────────────────────────────── internal static JsonObject InputContext(string text) => JsonNode.Parse($$""" @@ -890,6 +957,20 @@ private static DefenderRtpOptions Options(DefenderRtpOptions? overrides = null) ? Json(new { decision = "deny", reason = "prevention_blocked", message = "Blocked payload." }) : Json(new { decision = "allow" })); + /// + /// Waits until the agent's in-flight token acquisition has finished, whether it failed or not. An acquisition + /// leaves the in-flight map before its task completes, so a call made afterwards cannot join it. + /// + private static async Task WaitForTheAcquisition(DefenderRtpClient client) + { + if (client.PendingTokenAcquisition(Agent) is { } pending) + { + await Task.WhenAny(pending); + } + + client.PendingTokenAcquisition(Agent).Should().BeNull("an acquisition leaves the in-flight map as it completes"); + } + private static bool ContainsLoneSurrogate(JsonNode? node) => node switch { JsonValue value when value.TryGetValue(out var text) => HasLoneSurrogate(text), @@ -1032,12 +1113,19 @@ public RecordingHandler(Func SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var body = request.Content == null ? "{}" : await request.Content.ReadAsStringAsync(cancellationToken); - Calls.Add(new RecordedCall( + var call = new RecordedCall( request.Method, request.RequestUri!, request.Headers.Authorization?.ToString(), request.Headers.TryGetValues("x-ms-correlation-id", out var values) ? values.Single() : null, - JsonNode.Parse(body)!.AsObject())); + JsonNode.Parse(body)!.AsObject()); + + // Some tests send concurrently. + lock (Calls) + { + Calls.Add(call); + } + return await _respond(request, cancellationToken); } } diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index 6d552b4c..ded4bfb2 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -85,6 +85,48 @@ public void MapsADefenderDenyToAnAgentHooksVerdictWithEvidenceAndLabels() verdict.Evidence!.VerificationPointers!["correlation"].Should().Be("urn:a365:defender:cid-1"); } + [Fact] + public void MapsToAVerdictThatDoesNotShareTheResultsLabels() + { + var labels = new List { "PromptInjection" }; + var verdict = A365DefenderInterceptor.ToVerdict(new DefenderRtpEvaluationResult + { + Allowed = false, + Evaluated = true, + InterceptionPoint = "input", + CorrelationId = "cid-2", + Verdict = new DefenderRtpVerdict { Decision = "deny", ResultLabels = labels }, + }); + + labels.Clear(); + + verdict.ResultLabels.Should().Equal("PromptInjection"); + } + + [Fact] + public async Task KeepsTheVerdictWhenTheEvaluationCallbackChangesTheResult() + { + var harness = new Harness( + _ => Json(new + { + decision = "allow", + warnings = new[] { new { reason = "prevention_annotated", message = "Suspicious but allowed." } }, + result_labels = new[] { "MaliciousContentPropagation" }, + }), + onEvaluated: result => + { + ((IList)result.Verdict!.Warnings).Clear(); + ((IList)result.Verdict.ResultLabels).Clear(); + }); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-9"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + record.Proceeds.Should().BeTrue(); + record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "prevention_annotated"); + record.Verdict.ResultLabels.Should().Equal("MaliciousContentPropagation"); + } + [Fact] public async Task AllowsWithAWarningWhenFailOpenDefenderIsUnavailable() { diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index 02e38730..a28d9fe0 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -76,6 +76,9 @@ public sealed class DefenderRtpClient private readonly Func _idFactory; private readonly TimeProvider _timeProvider; private readonly ConcurrentDictionary _tokens = new(StringComparer.Ordinal); + + // Serializes writes to _tokens, so concurrent acquisitions cannot push it past MaxCachedTokens. + private readonly object _tokenCacheLock = new(); private readonly ConcurrentDictionary>> _inFlightTokens = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _sequences = new(StringComparer.Ordinal); @@ -764,6 +767,7 @@ private static string TransportError(string error, Exception exception) => static string? Text(JsonNode? node) => ReadString(node) is { Length: > 0 } text ? text : null; + // Read-only, so no observer of the result can change what the verdict says. return new DefenderRtpVerdict { Decision = ReadString(verdict["decision"])!, @@ -772,11 +776,13 @@ private static string TransportError(string error, Exception exception) => Warnings = (verdict["warnings"] as JsonArray ?? new JsonArray()) .OfType() .Select(warning => new DefenderRtpWarning(Text(warning["reason"]), Text(warning["message"]))) - .ToList(), + .ToList() + .AsReadOnly(), ResultLabels = (verdict["result_labels"] as JsonArray ?? new JsonArray()) .Select(Text) .OfType() - .ToList(), + .ToList() + .AsReadOnly(), // Read only from an object, so unexpected transform metadata never costs Defender its decision. TransformPath = Text((verdict["transform"] as JsonObject)?["path"]), }; @@ -846,7 +852,7 @@ private async Task GetAccessTokenAsync( CancellationToken cancellationToken) { var scope = Options.AuthenticationScope; - var key = string.Join(":", agent.TenantId, agent.AgentId, scope); + var key = TokenKey(agent, scope); var now = _timeProvider.GetUtcNow(); if (_tokens.TryGetValue(key, out var cached) && now < cached.ExpiresAt) { @@ -867,6 +873,19 @@ private async Task GetAccessTokenAsync( return await StartOrJoinAcquisition(key, agent, tokenResolver, scope).WaitAsync(cancellationToken).ConfigureAwait(false); } + /// + /// The token acquisition in flight for the agent, if any; for tests. Its task completes only after the + /// acquisition has left the in-flight map, so awaiting it orders a later call after it. + /// + internal Task? PendingTokenAcquisition(DefenderRtpAgentContext agent) => + _inFlightTokens.TryGetValue(TokenKey(agent, Options.AuthenticationScope), out var acquisition) ? acquisition.Value : null; + + /// The number of cached tokens; for tests. + internal int CachedTokenCount => _tokens.Count; + + private static string TokenKey(DefenderRtpAgentContext agent, string scope) => + string.Join(":", agent.TenantId, agent.AgentId, scope); + /// /// One acquisition per agent, tenant and scope, shared by concurrent callers. It is bounded by the /// configured timeout rather than by any caller's cancellation, and it leaves the in-flight map as it @@ -932,6 +951,21 @@ private async Task ResolveAndCacheTokenAsync( throw new InvalidOperationException("The Defender token resolver returned an expired token."); } + CacheToken(key, new CachedToken(token!, expiresAt)); + } + + return token!; + } + + /// + /// Caches a token within : at capacity, expired tokens go first, then the one + /// closest to expiry. Writers are serialized, so concurrent acquisitions cannot each evict the same entry and + /// then all insert. + /// + private void CacheToken(string key, CachedToken token) + { + lock (_tokenCacheLock) + { if (_tokens.Count >= MaxCachedTokens) { var now = _timeProvider.GetUtcNow(); @@ -946,10 +980,8 @@ private async Task ResolveAndCacheTokenAsync( } } - _tokens[key] = new CachedToken(token!, expiresAt); + _tokens[key] = token; } - - return token!; } private static DateTimeOffset? ReadExpiry(string token) diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index 06514047..4144e62a 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -37,7 +37,7 @@ public sealed class DefenderRtpOptions /// /// When true, an evaluation that returns no verdict blocks (A365_DEFENDER_RTP_FAIL_MODE=closed); - /// otherwise it is allowed and reported as not evaluated. + /// otherwise (open, the default) it is allowed and reported as not evaluated. /// public bool FailClosed { get; set; } @@ -54,11 +54,17 @@ public sealed class DefenderRtpOptions /// Reads the options from the process environment. /// The configured options. + /// A variable is set to a value it does not accept. public static DefenderRtpOptions FromEnvironment() => FromEnvironment(Environment.GetEnvironmentVariable); - /// Reads the options from the given variable lookup. + /// + /// Reads the options from the given variable lookup. A value a variable does not accept fails rather than + /// falling back to a default; in particular, A365_DEFENDER_RTP_FAIL_MODE must be open or + /// closed, so a typo cannot quietly weaken enforcement. + /// /// Returns the value of an environment variable, or null. /// The configured options. + /// A variable is set to a value it does not accept. public static DefenderRtpOptions FromEnvironment(Func getVariable) { if (getVariable == null) @@ -78,7 +84,7 @@ public static DefenderRtpOptions FromEnvironment(Func getVariab && (enabled.Equals("true", StringComparison.OrdinalIgnoreCase) || enabled == "1" || enabled.Equals("yes", StringComparison.OrdinalIgnoreCase)), - FailClosed = string.Equals(Read("A365_DEFENDER_RTP_FAIL_MODE"), "closed", StringComparison.OrdinalIgnoreCase), + FailClosed = ParseFailMode(Read("A365_DEFENDER_RTP_FAIL_MODE")), }; if (Read("A365_DEFENDER_RTP_ENDPOINT") is { } endpoint) @@ -147,5 +153,21 @@ private static int ParsePositive(string value, string name) => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0 ? parsed : throw new InvalidOperationException($"{name} must be a positive integer."); + + /// Whether the fail mode is closed. Unset means open; any value but open or closed is rejected. + private static bool ParseFailMode(string? value) + { + if (value == null || value.Equals("open", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (value.Equals("closed", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + throw new InvalidOperationException("A365_DEFENDER_RTP_FAIL_MODE must be \"open\" or \"closed\"."); + } } } diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 42351dbc..596413e0 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -41,7 +41,7 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// Register it on an emitter whose per-interceptor timeout is longer than /// , such as one from ; /// a shorter timeout turns a slow evaluation into a fail-closed agent-hooks host error. The evaluation - /// callback never changes the verdict: an exception it throws is logged and ignored. + /// callback runs once the verdict is decided and never changes it: an exception it throws is logged and ignored. /// /// public sealed class A365DefenderInterceptor : IInterceptor @@ -65,8 +65,9 @@ public sealed class A365DefenderInterceptor : IInterceptor /// fail mode. /// /// - /// Receives each evaluation, for logging and telemetry (for example the correlation id). It never changes - /// the verdict: an exception it throws is logged to , when given, and ignored. + /// Receives each evaluation once its verdict is decided, for logging and telemetry (for example the + /// correlation id). It never changes the verdict: an exception it throws is logged to + /// , when given, and ignored. /// /// Receives failures of . public A365DefenderInterceptor( @@ -111,8 +112,10 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio return new Verdict(Decision.Allow); } + // The verdict is decided before the callback sees the result, so nothing the callback does changes it. + var verdict = ToVerdict(result); NotifyEvaluated(result); - return ToVerdict(result); + return verdict; } /// @@ -155,7 +158,8 @@ public static Verdict ToVerdict(DefenderRtpEvaluationResult result) var authoritative = result.Evaluated && !(result.Truncated && result.Verdict?.Decision == "allow"); if (authoritative) { - var labels = result.Verdict?.ResultLabels is { Count: > 0 } resultLabels ? resultLabels : null; + // Copied, so the verdict never shares a list with the result. + var labels = result.Verdict?.ResultLabels is { Count: > 0 } resultLabels ? resultLabels.ToList() : null; if (result.Allowed) { var warnings = result.Verdict?.Warnings diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 55f002d5..82ef3dee 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -82,8 +82,8 @@ var record = await emitter.EmitUncheckedAsync(builder.Input(userMessage), cancel if (!record.Proceeds) { /* blocked: record.Verdict.Message */ } ``` -The evaluation callback is for logging and telemetry and never changes the verdict: an exception it throws is -logged (to the optional `ILogger` passed to `A365DefenderInterceptor`) and ignored. +The evaluation callback is for logging and telemetry. It runs once the verdict is decided and never changes it: an +exception it throws is logged (to the optional `ILogger` passed to `A365DefenderInterceptor`) and ignored. Agents built on Microsoft Agent Framework can register the same interceptor with `Microsoft.Agents.AI.AgentHooks`, which mediates model and tool calls. @@ -102,7 +102,7 @@ if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` |---|---| | `ENABLE_A365_DEFENDER_RTP` | `true` to call Defender | | `A365_DEFENDER_RTP_ENDPOINT` | the prevention endpoint, an absolute HTTPS URL: `https:///v1/protection/evaluate` | -| `A365_DEFENDER_RTP_FAIL_MODE` | `closed` blocks when no verdict is obtained; default is open | +| `A365_DEFENDER_RTP_FAIL_MODE` | `open` (default) or `closed`, which blocks when no verdict is obtained; any other value is rejected | | `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | the deadline for one evaluation, including token acquisition (default 10000) | | `A365_DEFENDER_RTP_AUTHENTICATION_SCOPE` | overrides the Defender API scope | | `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | clamps each content string sent; ids, names and roles are not truncated (default 20000). See **Content size** | From 7559201e6827e5340f19261056193ccd94c2f2a8 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 14:57:59 +0100 Subject: [PATCH 08/16] Rebuild the Defender envelope from spec fields and keep keys distinct - Build the envelope (session, tenant, trace, request_id, actor, model, agent) from its agent-hooks spec fields instead of cloning it, so nothing a host adds to an envelope object escapes the content budget, and large host values are never cloned. - When replacing lone surrogates in property names, keep keys distinct: a renamed key that would collide with another gets a numbered suffix, so no value is dropped from the copy Defender evaluates. - Recognize non-negative integers a host built from any integer type, so an int sequence or session turn is kept. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../Protection/DefenderRtpClientTests.cs | 47 ++++ .../Protection/Defender/DefenderRtpClient.cs | 225 ++++++++++-------- .../Protection/Defender/DefenderRtpOptions.cs | 7 +- .../AgentHooks/A365DefenderInterceptor.cs | 5 +- src/Tooling/Extensions/AgentHooks/README.md | 7 +- 5 files changed, 188 insertions(+), 103 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 89d66c5d..0d48ab92 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -183,6 +183,35 @@ public async Task RepairsLooselyFilledOptionalFields() body["actor"]!.ToJsonString().Should().Be("""{"id":"user"}"""); } + [Fact] + public async Task SendsOnlyTheSpecFieldsOfEnvelopeObjects() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 100 }); + var context = InputContext("hello"); + var bulk = new string('x', 10000); + context["sequence"] = 9; + context["session"] = new JsonObject { ["id"] = "s-1", ["started_at"] = "2026-10-07T12:00:00+02:00", ["turn"] = 2, ["metadata"] = bulk }; + context["tenant"] = new JsonObject { ["id"] = TenantId, ["name"] = "Contoso", ["notes"] = bulk }; + context["trace"] = new JsonObject { ["trace_id"] = "trace-1", ["span_id"] = "span-1", ["baggage"] = bulk }; + context["request_id"] = new JsonObject { ["blob"] = bulk }; + context["actor"] = new JsonObject { ["id"] = "user", ["kind"] = "human", ["profile"] = bulk }; + context["model"] = new JsonObject { ["id"] = "gpt-4o", ["params"] = new JsonObject { ["system"] = bulk } }; + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + result!.Truncated.Should().BeFalse("nothing under decision was cut"); + var body = handler.Calls.Single().Body; + DefenderContract.Errors(body).Should().BeEmpty(); + body["sequence"]!.GetValue().Should().Be(9, "an int sequence is kept, not renumbered"); + body["session"]!.ToJsonString().Should().Be("""{"id":"s-1","started_at":"2026-10-07T10:00:00.000Z","turn":2}"""); + body["tenant"]!.ToJsonString().Should().Be($$"""{"id":"{{TenantId}}","name":"Contoso"}"""); + body["trace"]!.ToJsonString().Should().Be("""{"trace_id":"trace-1","span_id":"span-1"}"""); + body["request_id"]!.GetValue().Should().Be("activity-id", "a request id that is not a string is replaced by the turn's"); + body["actor"]!.ToJsonString().Should().Be("""{"id":"user","kind":"human"}"""); + body["model"]!.ToJsonString().Should().Be("""{"id":"gpt-4o"}"""); + body.ToJsonString().Length.Should().BeLessThan(2000, "no envelope member escapes the content budget"); + } + [Theory] [InlineData("\"metadata\"")] [InlineData("""{ "tool": "metadata" }""")] @@ -385,6 +414,24 @@ public async Task ReplacesLoneSurrogatesSoDefenderCanParseTheRequest() body["extensions"]!["custom"]!.AsObject().Select(property => property.Key).Should().Equal("key\uFFFD"); } + [Fact] + public async Task KeepsEveryValueWhenReplacingLoneSurrogatesInKeysWouldCollide() + { + var handler = DeniesThePayload(); + using var httpClient = new HttpClient(handler); + var client = new DefenderRtpClient(Options(), httpClient); + var context = ToolContext("pre_tool_call", "x", null); + context["tool_call"]!["args"] = new JsonObject { ["q\uD800"] = Payload, ["q\uFFFD"] = "harmless" }; + + var result = await client.EvaluateHookContextAsync(context, Agent, new TokenSource().Resolve); + + result!.Evaluated.Should().BeTrue(); + result.Allowed.Should().BeFalse("the value under the renamed key still reached Defender"); + var body = handler.Calls.Single().Body; + DefenderContract.Errors(body).Should().BeEmpty(); + body["tool_call"]!["args"]!.ToJsonString().Should().Be(new JsonObject { ["q\uFFFD~2"] = Payload, ["q\uFFFD"] = "harmless" }.ToJsonString()); + } + [Fact] public async Task RejectsAContextWithoutAnAgentId() { diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index a28d9fe0..aa02d735 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -63,12 +63,6 @@ public sealed class DefenderRtpClient "trace", "input", "output", "target", "tool_call", "tool_result", "messages", "tools", "extensions", }; - // Envelope members: copied and repaired, never treated as content. - private static readonly string[] EnvelopeMembers = - { - "interception_point", "timestamp", "sequence", "request_id", "session", "agent", "tenant", "actor", "model", "trace", - }; - // Used when the caller passes no HttpClient; pooled connections are recycled so DNS changes are picked up. internal static readonly HttpClient SharedHttpClient = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }); @@ -258,8 +252,8 @@ public DefenderRtpEvaluationResult Unavailable( /// /// Builds the copy of the context Defender evaluates without cloning the host's content. The envelope is - /// copied and repaired so the request meets Defender's validation; the content under decision at the point - /// (the field target mirrors) is clamped first, and then the tool call's arguments at + /// rebuilt from its spec fields so the request meets Defender's validation; the content under decision at the + /// point (the field target mirrors) is clamped first, and then the tool call's arguments at /// post_tool_call, tool declarations, the newest message history, extensions and any other member /// share what remains of times /// . Lone surrogates are replaced, since Defender cannot @@ -270,70 +264,11 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou { var maxCharacters = Options.MaxContentCharacters; var budget = new ContentBudget((int)Math.Min(int.MaxValue, (long)ContentBudgetFactor * maxCharacters)); - var hook = new JsonObject { ["spec"] = AgentHooksSpec }; - foreach (var member in EnvelopeMembers) - { - if (context[member] is { } value) - { - hook[member] = value.DeepClone(); - } - } - - hook["timestamp"] = UtcTimestamp(hook["timestamp"]); - - var agentNode = hook["agent"] as JsonObject; - var agentId = FirstNonEmpty(agent.AgentObjectId, ReadString(agentNode?["id"]), agent.AgentId); - RequireString(agentId, "agent.id"); - var sessionId = ReadString((hook["session"] as JsonObject)?["id"]); - RequireString(sessionId, "session.id"); - if (!IsNonNegativeInteger(hook["sequence"])) - { - hook["sequence"] = NextSequence(sessionId!); - } - - var preparedAgent = new JsonObject - { - ["id"] = agentId, - ["framework"] = SanitizeFramework(FirstNonEmpty(ReadString(agentNode?["framework"]), agent.Framework)), - }; - if (FirstNonEmpty(ReadString(agentNode?["name"]), agent.AgentName) is { } name) - { - preparedAgent["name"] = name; - } - - if (ReadString(agentNode?["version"]) is { Length: > 0 } version) - { - preparedAgent["version"] = version; - } - - hook["agent"] = preparedAgent; - - // Defender requires tenant.id to equal the token's tid, and the token is always acquired for the - // agent's tenant, so a host-supplied value is never trusted over it. - var tenant = hook["tenant"] as JsonObject ?? new JsonObject(); - tenant["id"] = agent.TenantId; - hook["tenant"] = tenant; - - if (hook["actor"] == null && !string.IsNullOrEmpty(agent.UserId)) - { - hook["actor"] = new JsonObject { ["id"] = agent.UserId, ["kind"] = agent.ActorKind ?? "human" }; - } - - if (hook["request_id"] == null && !string.IsNullOrEmpty(agent.RequestId)) - { - hook["request_id"] = agent.RequestId; - } - - if (hook["model"] == null && !string.IsNullOrEmpty(agent.ModelName)) - { - hook["model"] = new JsonObject { ["id"] = agent.ModelName }; - } - - FitModelAndActor(hook); + var point = ReadString(context["interception_point"]); + var hook = PrepareEnvelope(context, agent, point); // The content under decision is sent twice, as the point's field and as target, so it may use half of // the budget; the rest of the context shares what it leaves. - var point = ReadString(hook["interception_point"]); var decisionBudget = new ContentBudget(budget.Remaining / 2); var decisionTruncated = false; string? toolName = null; @@ -429,45 +364,121 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou } /// - /// Repairs model and actor, which a host may fill loosely but Defender validates strictly (a - /// 400 would leave the call unverified). + /// Builds the envelope from its spec fields alone, repairing what a host may fill loosely but Defender + /// validates strictly (a 400 would leave the call unverified). Nothing else a host puts in an envelope object + /// is copied, so it cannot escape the content budget; ids and names are not truncated. /// - private static void FitModelAndActor(JsonObject hook) + private JsonObject PrepareEnvelope(JsonObject context, DefenderRtpAgentContext agent, string? point) { - if (hook.TryGetPropertyValue("model", out var model)) + var agentNode = context["agent"] as JsonObject; + var agentId = FirstNonEmpty(agent.AgentObjectId, ReadString(agentNode?["id"]), agent.AgentId); + RequireString(agentId, "agent.id"); + var sessionNode = context["session"] as JsonObject; + var sessionId = ReadString(sessionNode?["id"]); + RequireString(sessionId, "session.id"); + + var hook = new JsonObject + { + ["spec"] = AgentHooksSpec, + ["interception_point"] = point, + ["timestamp"] = UtcTimestamp(context["timestamp"]), + ["sequence"] = IsNonNegativeInteger(context["sequence"]) + ? context["sequence"]!.DeepClone() + : JsonValue.Create(NextSequence(sessionId!)), + }; + + if (FirstNonEmpty(ReadString(context["request_id"]), agent.RequestId) is { } requestId) + { + hook["request_id"] = requestId; + } + + var session = new JsonObject { ["id"] = sessionId }; + if (ReadString(sessionNode?["started_at"]) is { } startedAt + && DateTimeOffset.TryParse(startedAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var started)) + { + session["started_at"] = FormatUtc(started); + } + + if (sessionNode?["turn"] is { } turn && IsNonNegativeInteger(turn)) + { + session["turn"] = turn.DeepClone(); + } + + hook["session"] = session; + + var preparedAgent = new JsonObject + { + ["id"] = agentId, + ["framework"] = SanitizeFramework(FirstNonEmpty(ReadString(agentNode?["framework"]), agent.Framework)), + }; + if (FirstNonEmpty(ReadString(agentNode?["name"]), agent.AgentName) is { } name) + { + preparedAgent["name"] = name; + } + + if (ReadString(agentNode?["version"]) is { Length: > 0 } version) + { + preparedAgent["version"] = version; + } + + hook["agent"] = preparedAgent; + + // Defender requires tenant.id to equal the token's tid, and the token is always acquired for the + // agent's tenant, so a host-supplied value is never trusted over it. + var tenant = new JsonObject { ["id"] = agent.TenantId }; + if (ReadString((context["tenant"] as JsonObject)?["name"]) is { Length: > 0 } tenantName) { - if (ReadString((model as JsonObject)?["id"]) is { Length: > 0 } modelId) + tenant["name"] = tenantName; + } + + hook["tenant"] = tenant; + + // An actor or model the host set is repaired rather than replaced by the turn's. + var actorNode = context["actor"] ?? (string.IsNullOrEmpty(agent.UserId) + ? null + : new JsonObject { ["id"] = agent.UserId, ["kind"] = agent.ActorKind ?? "human" }); + if (actorNode is JsonObject actor) + { + var preparedActor = new JsonObject(); + if (ReadString(actor["id"]) is { Length: > 0 } actorId) { - hook["model"] = new JsonObject { ["id"] = modelId }; + preparedActor["id"] = actorId; } - else + + if (ReadString(actor["kind"]) is { } kind && ActorKinds.Contains(kind)) { - hook.Remove("model"); + preparedActor["kind"] = kind; } + + hook["actor"] = preparedActor; } - if (hook.TryGetPropertyValue("actor", out var actorNode)) + var modelId = context["model"] is { } model ? ReadString((model as JsonObject)?["id"]) : agent.ModelName; + if (!string.IsNullOrEmpty(modelId)) { - if (actorNode is JsonObject actor) - { - var prepared = new JsonObject(); - if (ReadString(actor["id"]) is { Length: > 0 } actorId) - { - prepared["id"] = actorId; - } + hook["model"] = new JsonObject { ["id"] = modelId }; + } - if (ReadString(actor["kind"]) is { } kind && ActorKinds.Contains(kind)) - { - prepared["kind"] = kind; - } + if (context["trace"] is JsonObject traceNode) + { + var trace = new JsonObject(); + if (ReadString(traceNode["trace_id"]) is { Length: > 0 } traceId) + { + trace["trace_id"] = traceId; + } - hook["actor"] = prepared; + if (ReadString(traceNode["span_id"]) is { Length: > 0 } spanId) + { + trace["span_id"] = spanId; } - else + + if (trace.Count > 0) { - hook.Remove("actor"); + hook["trace"] = trace; } } + + return hook; } /// @@ -1040,9 +1051,12 @@ private string UtcTimestamp(JsonNode? value) && DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp) ? timestamp : _timeProvider.GetUtcNow(); - return parsed.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + return FormatUtc(parsed); } + private static string FormatUtc(DateTimeOffset value) => + value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + private static string SanitizeFramework(string? framework) { var value = InvalidFrameworkCharacters.Replace((framework ?? string.Empty).Trim().ToLowerInvariant(), "-").Trim('-'); @@ -1104,6 +1118,8 @@ private static string SanitizeFramework(string? framework) /// /// A copy of the value with lone surrogates replaced by U+FFFD in every string and property name: they are /// not valid UTF-16, so Defender cannot parse a request that carries them. Valid surrogate pairs are kept. + /// Keys stay distinct, so no value is dropped: a renamed key that would collide with another gets a numbered + /// suffix. /// private static JsonNode? ReplaceLoneSurrogates(JsonNode? node) { @@ -1122,10 +1138,26 @@ private static string SanitizeFramework(string? framework) return items; case JsonObject obj: + // Keys that are valid UTF-16 keep their names; only the renamed ones can need a suffix. + var taken = new HashSet( + obj.Select(property => property.Key).Where(key => ReplaceLoneSurrogates(key) == key), + StringComparer.Ordinal); var properties = new JsonObject(); foreach (var property in obj) { - properties[ReplaceLoneSurrogates(property.Key)] = ReplaceLoneSurrogates(property.Value); + var name = ReplaceLoneSurrogates(property.Key); + if (name != property.Key) + { + var unique = name; + for (var suffix = 2; !taken.Add(unique); suffix++) + { + unique = $"{name}~{suffix}"; + } + + name = unique; + } + + properties[name] = ReplaceLoneSurrogates(property.Value); } return properties; @@ -1192,8 +1224,11 @@ private static int KeepSurrogatePairs(string value, int cut) => private static string? ReadString(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : null; + // Reads the number's JSON text, so a value a host built from any integer type counts, not only a long. private static bool IsNonNegativeInteger(JsonNode? node) => - node is JsonValue value && value.TryGetValue(out var number) && number >= 0; + node is JsonValue value + && value.GetValueKind() == JsonValueKind.Number + && long.TryParse(value.ToJsonString(), NumberStyles.None, CultureInfo.InvariantCulture, out _); private static string? FirstNonEmpty(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index 4144e62a..4caf4805 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -44,9 +44,10 @@ public sealed class DefenderRtpOptions /// /// Maximum characters per content string sent to Defender (A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS): /// input and output content, tool arguments and results, message content, tool descriptions and - /// schemas, extensions, and any member a host adds. Envelope fields such as ids, names and roles are - /// not truncated. The copy as a whole carries at most four times this many characters of content: the - /// content under decision first, then the rest of the context, with the oldest messages dropped first. + /// schemas, extensions, and any other member a host adds to the context. Envelope fields such as ids, names + /// and roles are not truncated, and envelope objects carry only their spec fields. The copy as a whole + /// carries at most four times this many characters of content: the content under decision first, then the + /// rest of the context, with the oldest messages dropped first. /// When the content under decision is cut, Defender evaluates a truncated copy and only its deny stands; an /// allow follows . Raise it for agents with long content. /// diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 596413e0..72eb94c2 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -28,8 +28,9 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// /// /// Defender receives a copy of the context fitted to its request validation (spec version, UTC - /// timestamp, target, spec-only tool members, repaired optional fields and clamped content). The - /// copy keeps the context's session, sequence and tool call ids; the host's context is not modified. + /// timestamp, target, envelope and tool objects with only their spec members, repaired optional fields + /// and clamped content). The copy keeps the context's session, sequence and tool call ids; the host's context is + /// not modified. /// /// /// When no verdict is obtained (no agent identity, identity resolution, transport, authentication or validation diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 82ef3dee..8af674a6 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -6,9 +6,10 @@ control contract (AGENT-HOOKS-0.1), using the .NET package `A365DefenderInterceptor` is an agent-hooks interceptor for Microsoft Defender for AI. Each context the host emits at the four points Defender evaluates is sent to the prevention endpoint -(`POST .../v1/protection/evaluate`) as a fitted copy: the client clones the context and fits it to Defender's -request validation (spec version, UTC timestamp, `target`, spec-only tool members, repaired optional fields -and clamped content), keeping its session, sequence and tool call ids. The host's context is not modified. +(`POST .../v1/protection/evaluate`) as a fitted copy: the client builds a copy of the context that meets +Defender's request validation (spec version, UTC timestamp, `target`, envelope and tool objects with only their +spec members, repaired optional fields and clamped content), keeping its session, sequence and tool call ids. The +host's context is not modified. Defender's verdict decides: | agent-hooks point | When | On `deny` | From f81cbbe39e1f91c88006cfd2a5d68c1e45b42641 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 15:18:40 +0100 Subject: [PATCH 09/16] Bound Defender content depth and keep reserved warning reasons out - Cut content nested more than 32 levels deep the way content over the size limit is cut (decision content marks the result Truncated), so the walk over the host's JSON stays shallow however deep it is and can no longer overflow the stack. - Map a Defender warning reason that is empty or in the host_error: namespace, which agent-hooks reserves for host failures, to defender:warning, so the emitter does not reject Defender's allow as an invalid verdict. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../Protection/DefenderRtpClientTests.cs | 25 +++++++++++++++++++ .../A365DefenderInterceptorTests.cs | 16 ++++++++++++ .../Protection/Defender/DefenderRtpClient.cs | 19 ++++++++++---- .../Protection/Defender/DefenderRtpOptions.cs | 3 ++- .../AgentHooks/A365DefenderInterceptor.cs | 15 ++++++++++- src/Tooling/Extensions/AgentHooks/README.md | 5 +++- 6 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 0d48ab92..1e5f42e8 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -366,6 +366,31 @@ public async Task CapsTheTotalSizeOfTheCopy() result.Error.Should().Be(DefenderRtpClient.TruncatedContentError); } + [Theory] + [InlineData(200)] + [InlineData(100_000)] + public async Task CutsContentNestedTooDeeply(int levels) + { + var handler = DeniesThePayload(); + using var httpClient = new HttpClient(handler); + var client = new DefenderRtpClient(Options(new DefenderRtpOptions { FailClosed = true }), httpClient); + JsonNode nested = JsonValue.Create(Payload)!; + for (var level = 0; level < levels; level++) + { + nested = new JsonObject { [string.Empty] = nested }; + } + + var context = ToolContext("pre_tool_call", "x", null); + context["tool_call"]!["args"] = nested; + + var result = await client.EvaluateHookContextAsync(context, Agent, new TokenSource().Resolve); + + result!.Evaluated.Should().BeTrue(); + result.Truncated.Should().BeTrue("the payload lies deeper than the copy Defender is sent"); + result.Allowed.Should().BeFalse("a fail-closed client does not let an allow of a cut copy through"); + DefenderContract.Errors(handler.Calls.Single().Body).Should().BeEmpty(); + } + [Fact] public async Task KeepsTheCalledToolsDeclarationWhenTheBudgetIsShort() { diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index ded4bfb2..7a987a66 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -127,6 +127,22 @@ public async Task KeepsTheVerdictWhenTheEvaluationCallbackChangesTheResult() record.Verdict.ResultLabels.Should().Equal("MaliciousContentPropagation"); } + [Fact] + public async Task KeepsAnAllowWhoseWarningUsesAReservedReason() + { + var harness = new Harness(_ => Json(new + { + decision = "allow", + warnings = new[] { new { reason = "host_error:spoofed", message = "Suspicious but allowed." } }, + })); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-10"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + record.Proceeds.Should().BeTrue("a reserved warning reason does not invalidate Defender's allow"); + record.Verdict.Warnings.Should().ContainSingle(warning => warning.Reason == "defender:warning" && warning.Message == "Suspicious but allowed."); + } + [Fact] public async Task AllowsWithAWarningWhenFailOpenDefenderIsUnavailable() { diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index aa02d735..c1b4d919 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -50,6 +50,10 @@ public sealed class DefenderRtpClient // A fitted copy carries at most this many times MaxContentCharacters of content in all. private const int ContentBudgetFactor = 4; + + // Content nested deeper than this is cut like content over the size limit, so the copy stays within common + // JSON parser depth limits and the walk over the host's JSON stays shallow however deep it is. + private const int MaxContentDepth = 32; private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromMinutes(5); private static readonly HashSet EvaluatedPoints = new(StringComparer.Ordinal) { "input", "pre_tool_call", "post_tool_call", "output" }; private static readonly HashSet ActorKinds = new(StringComparer.Ordinal) { "human", "service", "agent" }; @@ -1066,9 +1070,11 @@ private static string SanitizeFramework(string? framework) /// /// Copies a value with every string clamped to and the whole copy kept within /// . Each array element and property also costs at least one character, so the copy - /// stays bounded however the value is shaped. is set when anything was cut. + /// stays bounded however the value is shaped, and arrays and objects nested deeper than + /// are cut, so the recursion stays shallow. is set + /// when anything was cut. /// - private static JsonNode? Clamp(JsonNode? node, int maxCharacters, ContentBudget budget, ref bool truncated) + private static JsonNode? Clamp(JsonNode? node, int maxCharacters, ContentBudget budget, ref bool truncated, int depth = 0) { switch (node) { @@ -1080,6 +1086,9 @@ private static string SanitizeFramework(string? framework) var clamped = Truncate(text, limit); budget.Spend(clamped.Length); return JsonValue.Create(clamped); + case JsonArray or JsonObject when depth >= MaxContentDepth: + truncated = true; + return null; case JsonArray array: var items = new JsonArray(); foreach (var item in array) @@ -1091,7 +1100,7 @@ private static string SanitizeFramework(string? framework) } budget.Spend(1); - items.Add(Clamp(item, maxCharacters, budget, ref truncated)); + items.Add(Clamp(item, maxCharacters, budget, ref truncated, depth + 1)); } return items; @@ -1106,7 +1115,7 @@ private static string SanitizeFramework(string? framework) } budget.Spend(property.Key.Length + 1); - properties[property.Key] = Clamp(property.Value, maxCharacters, budget, ref truncated); + properties[property.Key] = Clamp(property.Value, maxCharacters, budget, ref truncated, depth + 1); } return properties; @@ -1119,7 +1128,7 @@ private static string SanitizeFramework(string? framework) /// A copy of the value with lone surrogates replaced by U+FFFD in every string and property name: they are /// not valid UTF-16, so Defender cannot parse a request that carries them. Valid surrogate pairs are kept. /// Keys stay distinct, so no value is dropped: a renamed key that would collide with another gets a numbered - /// suffix. + /// suffix. It only walks the fitted copy, whose depth bounds. /// private static JsonNode? ReplaceLoneSurrogates(JsonNode? node) { diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index 4caf4805..8743e9c3 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -47,7 +47,8 @@ public sealed class DefenderRtpOptions /// schemas, extensions, and any other member a host adds to the context. Envelope fields such as ids, names /// and roles are not truncated, and envelope objects carry only their spec fields. The copy as a whole /// carries at most four times this many characters of content: the content under decision first, then the - /// rest of the context, with the oldest messages dropped first. + /// rest of the context, with the oldest messages dropped first. Content nested more than 32 levels deep is cut + /// the same way. /// When the content under decision is cut, Defender evaluates a truncated copy and only its deny stands; an /// allow follows . Raise it for agents with long content. /// diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 72eb94c2..30b8938f 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -50,6 +50,9 @@ public sealed class A365DefenderInterceptor : IInterceptor /// The name the interceptor is registered under. public const string Name = "defender"; + // agent-hooks reserves this reason namespace for failures the host synthesizes. + private const string ReservedReasonPrefix = "host_error:"; + private static readonly Regex InvalidReasonCharacters = new("[^A-Za-z0-9_.-]", RegexOptions.Compiled | RegexOptions.CultureInvariant); private readonly DefenderRtpClient _client; @@ -164,7 +167,7 @@ public static Verdict ToVerdict(DefenderRtpEvaluationResult result) if (result.Allowed) { var warnings = result.Verdict?.Warnings - .Select(warning => new Warning(warning.Reason ?? $"{Name}:warning", warning.Message ?? string.Empty)) + .Select(warning => new Warning(WarningReason(warning.Reason), warning.Message ?? string.Empty)) .ToList(); return new Verdict( Decision.Allow, @@ -192,6 +195,16 @@ public static Verdict ToVerdict(DefenderRtpEvaluationResult result) Message: result.BlockReason ?? "Security validation is unavailable and this agent is configured to fail closed.", Warnings: unverified); } + + /// + /// Defender's warning reason, or the interceptor's own when it is empty or in the host_error: + /// namespace, which agent-hooks reserves for host failures: the emitter rejects a verdict that uses it, which + /// would turn Defender's allow into a fail-closed host error whatever the fail mode. + /// + private static string WarningReason(string? reason) => + string.IsNullOrWhiteSpace(reason) || reason.StartsWith(ReservedReasonPrefix, StringComparison.Ordinal) + ? $"{Name}:warning" + : reason; } /// Agent 365 protection helpers for agent-hooks hosts. diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 8af674a6..7c42a3f3 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -19,7 +19,9 @@ Defender's verdict decides: | `post_tool_call` | a tool result, before the agent uses it | the result is withheld | | `output` | the reply, before it is sent | the reply is replaced | -Other points (`agent_startup`, model calls, `agent_shutdown`) are allowed without a call. +Other points (`agent_startup`, model calls, `agent_shutdown`) are allowed without a call. Defender's warnings and +result labels are passed through to the agent-hooks verdict; a warning reason in the `host_error:` namespace, which +agent-hooks reserves for host failures, is reported as `defender:warning`. ## Authentication @@ -112,6 +114,7 @@ if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` truncated before it is sent, and the request as a whole carries at most four times that much content: the content under decision at the point (the input, the tool call's arguments, the tool result or the reply) first, then tool declarations, the newest message history, extensions and other members, with the oldest messages dropped first. +Content nested more than 32 levels deep is cut the same way. The agent's own context is not modified. When the content under decision was cut, Defender has not seen all of it: its deny still blocks, but its allow does not cover the rest, so the result follows the fail mode. Fail open allows with a `defender:unverified` warning; fail closed denies with `runtime_error:defender_unverified`. Raise the From b96fee0f8847ede20ae5d0a03b5f8c504ebf16f0 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 15:38:49 +0100 Subject: [PATCH 10/16] Charge all copied structure to the Defender budget, keep exception text out - Charge every element, key and null the fitted copy carries: tool declarations their element and name, messages their element, role and member names, extensions and other top-level members their keys, nulls one character and numbers and Booleans their JSON text. A context padded with many empty or null items now stays within the 4x bound. - Bring the called tool first with one scan instead of a sort, and check messages newest first as they are copied, so the work stays within the budget too; copying stops at a message without a role or content. - Record only the exception's type when resolving the identity or evaluating throws, so its message cannot reach the verdict, and log the exception instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../Protection/DefenderRtpClientTests.cs | 39 +++++- .../A365DefenderInterceptorTests.cs | 14 ++ .../Protection/Defender/DefenderRtpClient.cs | 128 ++++++++++++------ .../Protection/Defender/DefenderRtpOptions.cs | 4 +- .../AgentHooks/A365DefenderInterceptor.cs | 12 +- src/Tooling/Extensions/AgentHooks/README.md | 7 +- 6 files changed, 152 insertions(+), 52 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 1e5f42e8..94dc3746 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -252,7 +252,8 @@ public async Task ClampsEachContentStringButNotTheEnvelope() body["tools"]![0]!["description"]!.GetValue().Should().Be(new string('d', 76) + "...[truncated 74 chars]"); body["messages"]![0]!["content"]!.GetValue().Should().Be(new string('m', 76) + "...[truncated 74 chars]"); body["extensions"]!["custom"]!["note"]!.GetValue().Should().Be(new string('e', 76) + "...[truncated 74 chars]"); - body["metadata"]!.GetValue().Should().Be(new string('h', 64) + "...[truncated 86 chars]", "it gets what the rest left"); + // Names, roles and keys are charged too, which leaves the last member 21 characters: too few for a marker. + body["metadata"]!.GetValue().Should().Be(new string('h', 21), "it gets what the rest left"); body["messages"]![0]!["role"]!.GetValue().Should().Be("assistant"); body["tools"]![0]!["name"]!.GetValue().Should().Be("search_web"); body["trace"]!.ToJsonString().Should().Be("""{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}"""); @@ -391,6 +392,42 @@ public async Task CutsContentNestedTooDeeply(int levels) DefenderContract.Errors(handler.Calls.Single().Body).Should().BeEmpty(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task KeepsTheCopyBoundedHoweverManyEmptyOrNullItemsTheContextHas(bool padTheContentUnderDecision) + { + const int Count = 50_000; + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 1000 }); + var context = ToolContext("pre_tool_call", "x", null); + context["tools"] = new JsonArray(Enumerable.Range(0, Count) + .Select(index => (JsonNode?)new JsonObject { ["name"] = $"t{index}" }) + .ToArray()); + context["messages"] = new JsonArray(Enumerable.Range(0, Count) + .Select(_ => (JsonNode?)new JsonObject { ["role"] = "user", ["content"] = new JsonObject() }) + .ToArray()); + var extensions = new JsonObject(); + for (var index = 0; index < Count; index++) + { + extensions[$"x{index}"] = null; + context[$"pad{index}"] = (index % 3) switch { 0 => null, 1 => (JsonNode?)true, _ => (JsonNode?)index }; + } + + context["extensions"] = extensions; + if (padTheContentUnderDecision) + { + context["tool_call"]!["args"] = new JsonObject { ["items"] = new JsonArray(new JsonNode?[Count]) }; + } + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + result!.Evaluated.Should().BeTrue(); + result.Truncated.Should().Be(padTheContentUnderDecision, "only cutting the content under decision makes an allow unverified"); + var body = handler.Calls.Single().Body; + DefenderContract.Errors(body).Should().BeEmpty(); + body.ToJsonString().Length.Should().BeLessThan(40_000, "every copied element, key and null is charged, however many the context has"); + } + [Fact] public async Task KeepsTheCalledToolsDeclarationWhenTheBudgetIsShort() { diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index 7a987a66..dc0f95c6 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -224,6 +224,20 @@ public async Task FollowsTheFailModeWhenResolvingTheCallFails(bool failClosed) harness.Bodies.Should().BeEmpty(); } + [Fact] + public async Task RecordsOnlyTheExceptionTypeWhenTheEvaluationFails() + { + const string Secret = "client_secret=do-not-leak"; + var harness = new Harness(_ => Json(new { decision = "allow" }), resolveCall: _ => throw new InvalidOperationException(Secret)); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-12"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + + AssertFollowsTheFailMode(record, failClosed: false); + harness.Evaluations.Should().ContainSingle().Which.Error.Should().Be("evaluation failed (InvalidOperationException)"); + record.Verdict.Warnings.Should().OnlyContain(warning => warning.Message == null || !warning.Message.Contains(Secret)); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index c1b4d919..04b68687 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -350,17 +350,12 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou AddTools(hook, context, toolName, maxCharacters, budget); AddMessages(hook, context, maxCharacters, budget); AddExtensions(hook, context, maxCharacters, budget); - foreach (var property in context) + foreach (var property in context.Where(property => !KnownMembers.Contains(property.Key))) { - if (budget.Remaining == 0) + if (!AddMember(hook, property.Key, property.Value, maxCharacters, budget, ref contextCut)) { break; } - - if (!KnownMembers.Contains(property.Key)) - { - hook[property.Key] = Clamp(property.Value, maxCharacters, budget, ref contextCut); - } } truncated = decisionTruncated; @@ -487,36 +482,39 @@ private JsonObject PrepareEnvelope(JsonObject context, DefenderRtpAgentContext a /// /// Copies the valid tool declarations (a name, and a string description and object schema when present) - /// within the budget, the called tool's first so a short budget never drops it. At a tool point without - /// declarations, the called tool is declared from the a365 extension. + /// within the budget, the called tool's first so a short budget never drops it. Each declaration is charged + /// for its element and name as well as its content, so name-only declarations are bounded too. At a tool point + /// without declarations, the called tool is declared from the a365 extension. /// private static void AddTools(JsonObject hook, JsonObject context, string? toolName, int maxCharacters, ContentBudget budget) { var cut = false; var tools = new JsonArray(); var declared = (context["tools"] as JsonArray ?? new JsonArray()).OfType() - .OrderBy(tool => toolName != null && ReadString(tool["name"]) == toolName ? 0 : 1); - foreach (var tool in declared) + .Where(tool => ReadString(tool["name"]) is { Length: > 0 }); + + // One scan finds the called tool; the rest keep their order, and copying stops when the budget runs out. + var calledTool = toolName == null ? null : declared.FirstOrDefault(tool => ReadString(tool["name"]) == toolName); + var ordered = calledTool == null ? declared : declared.Where(tool => !ReferenceEquals(tool, calledTool)).Prepend(calledTool); + foreach (var tool in ordered) { - if (budget.Remaining == 0) + var declaredName = ReadString(tool["name"])!; + var cost = 1 + MemberCost("name", declaredName); + if (budget.Remaining < cost) { break; } - if (ReadString(tool["name"]) is not { Length: > 0 } declaredName) - { - continue; - } - + budget.Spend(cost); var declaration = new JsonObject { ["name"] = declaredName }; if (tool["description"] is JsonValue description && ReadString(description) != null) { - declaration["description"] = Clamp(description, maxCharacters, budget, ref cut); + AddMember(declaration, "description", description, maxCharacters, budget, ref cut); } if (tool["schema"] is JsonObject schema) { - declaration["schema"] = Clamp(schema, maxCharacters, budget, ref cut); + AddMember(declaration, "schema", schema, maxCharacters, budget, ref cut); } tools.Add(declaration); @@ -527,8 +525,8 @@ private static void AddTools(JsonObject hook, JsonObject context, string? toolNa var declaration = new JsonObject { ["name"] = toolName }; // An extension namespace may hold any JSON value, so each level's shape is checked before it is read. if ((context["extensions"] as JsonObject)?[A365Extension] is JsonObject a365 - && a365["tool"] is JsonObject calledTool - && calledTool["description"] is JsonValue description + && a365["tool"] is JsonObject calledToolExtension + && calledToolExtension["description"] is JsonValue description && ReadString(Clamp(description, maxCharacters, budget, ref cut)) is { Length: > 0 } clamped) { declaration["description"] = clamped; @@ -545,28 +543,46 @@ private static void AddTools(JsonObject hook, JsonObject context, string? toolNa /// /// Copies the message history within the budget, newest first, so older messages are dropped before newer - /// ones. A history with a message lacking a role or content is dropped whole, since Defender rejects it. + /// ones. Each message is charged for its element, role and member names as well as its content, so even empty + /// messages are bounded. Copying stops at a message without a role or content, which Defender rejects, so + /// only the messages after it are sent. /// private static void AddMessages(JsonObject hook, JsonObject context, int maxCharacters, ContentBudget budget) { - if (context["messages"] is not JsonArray messages - || !messages.All(message => message is JsonObject item - && ReadString(item["role"]) is { Length: > 0 } - && item.ContainsKey("content"))) + if (context["messages"] is not JsonArray messages) { return; } var cut = false; var kept = new List(); - for (var index = messages.Count - 1; index >= 0 && budget.Remaining > 0; index--) + for (var index = messages.Count - 1; index >= 0; index--) { - var copy = new JsonObject(); - foreach (var property in (JsonObject)messages[index]!) + if (messages[index] is not JsonObject message + || ReadString(message["role"]) is not { Length: > 0 } role + || !message.TryGetPropertyValue("content", out var content)) + { + break; + } + + var cost = 1 + MemberCost("role", role) + MemberCost("content", string.Empty); + if (budget.Remaining < cost) { - copy[property.Key] = property.Key == "role" - ? property.Value?.DeepClone() - : Clamp(property.Value, maxCharacters, budget, ref cut); + break; + } + + budget.Spend(cost); + var copy = new JsonObject + { + ["role"] = role, + ["content"] = Clamp(content, maxCharacters, budget, ref cut), + }; + foreach (var property in message.Where(property => property.Key is not ("role" or "content"))) + { + if (!AddMember(copy, property.Key, property.Value, maxCharacters, budget, ref cut)) + { + break; + } } kept.Add(copy); @@ -589,17 +605,12 @@ private static void AddExtensions(JsonObject hook, JsonObject context, int maxCh var cut = false; var kept = new JsonObject(); - foreach (var property in extensions) + foreach (var property in extensions.Where(property => ExtensionKeyPattern.IsMatch(property.Key))) { - if (budget.Remaining == 0) + if (!AddMember(kept, property.Key, property.Value, maxCharacters, budget, ref cut)) { break; } - - if (ExtensionKeyPattern.IsMatch(property.Key)) - { - kept[property.Key] = Clamp(property.Value, maxCharacters, budget, ref cut); - } } if (kept.Count > 0) @@ -608,6 +619,26 @@ private static void AddExtensions(JsonObject hook, JsonObject context, int maxCh } } + /// + /// Adds a member within the budget: its name is charged as charges an object's property + /// names, and its value is clamped. Returns false, setting , when the name does not fit. + /// + private static bool AddMember(JsonObject target, string name, JsonNode? value, int maxCharacters, ContentBudget budget, ref bool cut) + { + if (budget.Remaining < name.Length + 1) + { + cut = true; + return false; + } + + budget.Spend(name.Length + 1); + target[name] = Clamp(value, maxCharacters, budget, ref cut); + return true; + } + + /// What a member whose value is never truncated (a name or role) costs: its name, and its value. + private static int MemberCost(string name, string value) => name.Length + 1 + value.Length; + private static JsonObject ToArguments(JsonNode? args, int maxCharacters, ContentBudget budget, ref bool truncated) { var clamped = Clamp(args ?? new JsonObject(), maxCharacters, budget, ref truncated); @@ -1069,16 +1100,18 @@ private static string SanitizeFramework(string? framework) /// /// Copies a value with every string clamped to and the whole copy kept within - /// . Each array element and property also costs at least one character, so the copy - /// stays bounded however the value is shaped, and arrays and objects nested deeper than - /// are cut, so the recursion stays shallow. is set - /// when anything was cut. + /// . Strings cost their characters, numbers and Booleans their JSON text, and each + /// null, array element and property name at least one character, so the copy stays bounded however the value + /// is shaped; arrays and objects nested deeper than are cut, so the recursion + /// stays shallow. is set when anything was cut. /// private static JsonNode? Clamp(JsonNode? node, int maxCharacters, ContentBudget budget, ref bool truncated, int depth = 0) { switch (node) { case null: + // A null costs a character like any copied value, so padding with nulls is bounded too. + budget.Spend(1); return null; case JsonValue value when value.TryGetValue(out var text): var limit = Math.Min(maxCharacters, budget.Remaining); @@ -1120,6 +1153,15 @@ private static string SanitizeFramework(string? framework) return properties; default: + // Numbers and Booleans cost their JSON text, as strings cost their characters. + var literal = node.ToJsonString(); + if (literal.Length > budget.Remaining) + { + truncated = true; + return null; + } + + budget.Spend(literal.Length); return node.DeepClone(); } } diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index 8743e9c3..b72d054a 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -47,8 +47,8 @@ public sealed class DefenderRtpOptions /// schemas, extensions, and any other member a host adds to the context. Envelope fields such as ids, names /// and roles are not truncated, and envelope objects carry only their spec fields. The copy as a whole /// carries at most four times this many characters of content: the content under decision first, then the - /// rest of the context, with the oldest messages dropped first. Content nested more than 32 levels deep is cut - /// the same way. + /// rest of the context, with the oldest messages dropped first. Names, keys and nulls count toward it too, and + /// content nested more than 32 levels deep is cut the same way. /// When the content under decision is cut, Defender evaluates a truncated copy and only its deny stands; an /// allow follows . Raise it for agents with long content. /// diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index 30b8938f..d3f8acf6 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -66,14 +66,15 @@ public sealed class A365DefenderInterceptor : IInterceptor /// Returns the agent identity and token resolver for a context, for example from the current turn. It is /// invoked only when Defender RTP is enabled and Defender evaluates the context's point. When it returns /// null (no agent identity is available) or throws, the context is not sent and the verdict follows the - /// fail mode. + /// fail mode; the result records only the type of an exception, which is logged to + /// . /// /// /// Receives each evaluation once its verdict is decided, for logging and telemetry (for example the /// correlation id). It never changes the verdict: an exception it throws is logged to /// , when given, and ignored. /// - /// Receives failures of . + /// Receives failures of the evaluation and of . public A365DefenderInterceptor( DefenderRtpClient client, Func resolveCall, @@ -107,8 +108,11 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { - // A failure to resolve the identity or to evaluate is never a verdict: it follows the fail mode. - result = _client.Unavailable(point, $"{ex.GetType().Name}: {ex.Message}"); + // A failure to resolve the identity or to evaluate is never a verdict: it follows the fail mode. Only + // the exception's type reaches the result, and so the verdict, since its message can carry + // credentials or content; the exception itself goes to the logger. + _logger?.LogWarning(ex, "The Defender evaluation failed at {InterceptionPoint}; the fail mode applies.", point); + result = _client.Unavailable(point, $"evaluation failed ({ex.GetType().Name})"); } if (result == null) diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 7c42a3f3..15e8e428 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -86,7 +86,9 @@ if (!record.Proceeds) { /* blocked: record.Verdict.Message */ } ``` The evaluation callback is for logging and telemetry. It runs once the verdict is decided and never changes it: an -exception it throws is logged (to the optional `ILogger` passed to `A365DefenderInterceptor`) and ignored. +exception it throws is logged (to the optional `ILogger` passed to `A365DefenderInterceptor`) and ignored. When +resolving the identity or evaluating throws, the result and verdict record only the exception's type; the exception +itself goes to the same logger. Agents built on Microsoft Agent Framework can register the same interceptor with `Microsoft.Agents.AI.AgentHooks`, which mediates model and tool calls. @@ -114,7 +116,8 @@ if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` truncated before it is sent, and the request as a whole carries at most four times that much content: the content under decision at the point (the input, the tool call's arguments, the tool result or the reply) first, then tool declarations, the newest message history, extensions and other members, with the oldest messages dropped first. -Content nested more than 32 levels deep is cut the same way. +Names, keys and nulls count toward that too, so a context padded with many empty or null items cannot inflate the +request. Content nested more than 32 levels deep is cut the same way. The agent's own context is not modified. When the content under decision was cut, Defender has not seen all of it: its deny still blocks, but its allow does not cover the rest, so the result follows the fail mode. Fail open allows with a `defender:unverified` warning; fail closed denies with `runtime_error:defender_unverified`. Raise the From 5ccece67429f5ac664c01de658c6f926baab8d9e Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 16:02:09 +0100 Subject: [PATCH 11/16] Bound token acquisition and tool reads, keep sequences and callbacks safe - Bound the token resolver's task by the timeout as well as asking it to stop, and observe a task left behind, so a resolver that ignores cancellation cannot hold the identity's acquisition, and every later evaluation, forever. - Run the evaluation callback on the thread pool once the verdict is decided, outside the emitter's interceptor timeout, so a slow callback cannot turn the verdict into a fail-closed host error. - Give a session the client stops tracking a sequence above every sequence it handed out for a dropped session, so a session that comes back never repeats or goes back. - Read only as many tool declarations and extension namespaces as the budget can hold, charging skipped entries too; a called tool beyond them is declared from its name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../Protection/DefenderRtpClientTests.cs | 52 +++++++- .../A365DefenderInterceptorTests.cs | 66 +++++++++- .../Protection/Defender/DefenderRtpClient.cs | 114 ++++++++++++++---- .../AgentHooks/A365DefenderInterceptor.cs | 28 +++-- src/Tooling/Extensions/AgentHooks/README.md | 12 +- 5 files changed, 220 insertions(+), 52 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 94dc3746..1990c63f 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -429,7 +429,7 @@ public async Task KeepsTheCopyBoundedHoweverManyEmptyOrNullItemsTheContextHas(bo } [Fact] - public async Task KeepsTheCalledToolsDeclarationWhenTheBudgetIsShort() + public async Task DeclaresTheCalledToolFromItsNameWhenItsDeclarationIsBeyondTheBudget() { var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 20 }); var context = ToolContext("pre_tool_call", "x", null); @@ -442,7 +442,35 @@ public async Task KeepsTheCalledToolsDeclarationWhenTheBudgetIsShort() var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); - body["tools"]![0]!.ToJsonString().Should().Be("""{"name":"FetchPage","description":"Reads a page."}"""); + body["tools"]!.AsArray().Select(tool => tool!["name"]!.GetValue()).Should().Equal( + new[] { "FetchPage", "Other0", "Other1" }, + "only the entries the budget holds are read"); + body["tools"]![0]!.ToJsonString().Should().Be("""{"name":"FetchPage"}""", "a called tool beyond them is declared from its name"); + } + + [Fact] + public async Task KeepsTheSequenceIncreasingForASessionThatWasDropped() + { + var handler = new RecordingHandler(_ => Json(new { decision = "allow" })); + using var httpClient = new HttpClient(handler); + var client = new DefenderRtpClient(Options(), httpClient) { MaxTrackedSessions = 1 }; + var tokens = new TokenSource(); + + async Task Send(string sessionId) + { + var context = InputContext("hello"); + context.Remove("sequence"); + context["session"] = new JsonObject { ["id"] = sessionId }; + await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + return handler.Calls[^1].Body["sequence"]!.GetValue(); + } + + var first = new[] { await Send("a"), await Send("a"), await Send("a") }; + await Send("b"); + var resumed = await Send("a"); + + first.Should().Equal(1, 2, 3); + resumed.Should().BeGreaterThan(3, "a session the client stopped tracking resumes above every sequence it was given"); } [Fact] @@ -970,6 +998,23 @@ public async Task KeepsTheTokenCacheBoundedUnderConcurrentAcquisitions() client.CachedTokenCount.Should().BeLessThanOrEqualTo(100, "the cache holds at most 100 tokens however acquisitions interleave"); } + [Fact] + public async Task ReleasesAnAcquisitionWhoseResolverIgnoresTheDeadline() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { Timeout = TimeSpan.FromMilliseconds(300) }); + var never = new TaskCompletionSource(); + DefenderRtpTokenResolver hung = (_, _, _, _) => never.Task; + + var first = await client.EvaluateHookContextAsync(InputContext("one"), Agent, hung); + await WaitForTheAcquisition(client); + var second = await client.EvaluateHookContextAsync(InputContext("two"), Agent, tokens.Resolve); + + first!.Evaluated.Should().BeFalse(); + first.Error.Should().Be("entra token timeout"); + second!.Evaluated.Should().BeTrue("a resolver that ignores the deadline does not hold the identity's acquisition"); + handler.Calls.Should().ContainSingle(); + } + // ─── options ───────────────────────────────────────────────────────────── [Fact] @@ -1074,7 +1119,8 @@ private static async Task WaitForTheAcquisition(DefenderRtpClient client) { if (client.PendingTokenAcquisition(Agent) is { } pending) { - await Task.WhenAny(pending); + // Bounded, so an acquisition that never ends fails the test instead of hanging it. + await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(10))); } client.PendingTokenAcquisition(Agent).Should().BeNull("an acquisition leaves the in-flight map as it completes"); diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index dc0f95c6..b780fc63 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -41,7 +41,7 @@ public async Task ForwardsTheEmittedContextAndAllows() body["tenant"]!["id"]!.GetValue().Should().Be(TenantId); body["actor"]!.ToJsonString().Should().Be("""{"id":"user-object-id","kind":"human"}"""); JsonNode.DeepEquals(body["target"], body["input"]).Should().BeTrue(); - harness.Evaluations.Should().ContainSingle().Which.CorrelationId.Should().Be(harness.CorrelationIds.Single()); + (await harness.EvaluationsAsync()).Should().ContainSingle().Which.CorrelationId.Should().Be(harness.CorrelationIds.Single()); } [Fact] @@ -179,6 +179,29 @@ public async Task KeepsTheVerdictWhenTheEvaluationCallbackThrows(string decision } } + [Fact] + public async Task DoesNotLetASlowEvaluationCallbackDecideTheVerdict() + { + var release = new ManualResetEventSlim(); + var harness = new Harness( + _ => Json(new { decision = "allow" }), + timeout: TimeSpan.FromMilliseconds(500), + onEvaluated: _ => release.Wait(TimeSpan.FromSeconds(30)), + tokenResolver: async (_, _, _, _) => + { + // Asynchronous like a real call, so the emitter is already timing the interceptor when the callback runs. + await Task.Delay(10).ConfigureAwait(false); + return Token; + }); + var builder = new AgentContextBuilder(AgentId, "agent-framework", "s-13"); + + var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); + release.Set(); + + record.Proceeds.Should().BeTrue("the callback runs after the verdict, outside the emitter's interceptor timeout"); + record.Verdict.Decision.Should().Be(Decision.Allow); + } + [Fact] public async Task ResolvesTheCallOnlyForPointsDefenderEvaluatesAndOnlyWhenEnabled() { @@ -234,7 +257,7 @@ public async Task RecordsOnlyTheExceptionTypeWhenTheEvaluationFails() var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); AssertFollowsTheFailMode(record, failClosed: false); - harness.Evaluations.Should().ContainSingle().Which.Error.Should().Be("evaluation failed (InvalidOperationException)"); + (await harness.EvaluationsAsync()).Should().ContainSingle().Which.Error.Should().Be("evaluation failed (InvalidOperationException)"); record.Verdict.Warnings.Should().OnlyContain(warning => warning.Message == null || !warning.Message.Contains(Secret)); } @@ -370,7 +393,7 @@ public async Task FollowsTheFailModeWhenNoIdentityIsResolved(bool failClosed) AssertFollowsTheFailMode(record, failClosed); harness.Bodies.Should().BeEmpty(); - harness.Evaluations.Should().ContainSingle().Which.Error.Should().Be("no agent identity was resolved"); + (await harness.EvaluationsAsync()).Should().ContainSingle().Which.Error.Should().Be("no agent identity was resolved"); } /// A fake Defender that denies only content containing . @@ -408,6 +431,9 @@ private static string Token private sealed class Harness { + private readonly List _evaluations = new(); + private readonly SemaphoreSlim _evaluated = new(0); + public Harness( Func respond, bool failClosed = false, @@ -416,10 +442,16 @@ public Harness( Action? onEvaluated = null, Func? resolveCall = null, DefenderRtpTokenResolver? tokenResolver = null, - bool enabled = true) + bool enabled = true, + TimeSpan? timeout = null) { var handler = new FakeEndpoint(respond, Bodies, CorrelationIds); var options = new DefenderRtpOptions { Enabled = enabled, Endpoint = new Uri(Endpoint), FailClosed = failClosed }; + if (timeout is { } deadline) + { + options.Timeout = deadline; + } + var client = new DefenderRtpClient(options, new HttpClient(handler)); DefenderRtpTokenResolver tokens = tokenResolver ?? ((_, _, _, _) => Task.FromResult(Token)); var agent = new DefenderRtpAgentContext { AgentId = agentId, TenantId = TenantId, UserId = "user-object-id" }; @@ -427,7 +459,7 @@ public Harness( .AddA365Defender(new A365DefenderInterceptor( client, resolveCall ?? (_ => resolveNothing ? null : new A365DefenderCall(agent, tokens)), - onEvaluated ?? Evaluations.Add)); + onEvaluated ?? Record)); } public InterceptionEmitter Emitter { get; } @@ -436,7 +468,29 @@ public Harness( public List CorrelationIds { get; } = new(); - public List Evaluations { get; } = new(); + /// Waits for the evaluations the callback receives, which runs on the thread pool after the verdict. + public async Task> EvaluationsAsync(int count = 1) + { + for (var received = 0; received < count; received++) + { + (await _evaluated.WaitAsync(TimeSpan.FromSeconds(10))).Should().BeTrue("the callback runs soon after the verdict"); + } + + lock (_evaluations) + { + return _evaluations.ToList(); + } + } + + private void Record(DefenderRtpEvaluationResult result) + { + lock (_evaluations) + { + _evaluations.Add(result); + } + + _evaluated.Release(); + } } /// An endpoint that never answers, so only a deadline ends the request. diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index 04b68687..1afd521d 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -46,7 +46,6 @@ public sealed class DefenderRtpClient private const string A365Extension = "a365"; private const int MaxErrorDetailCharacters = 200; private const int MaxCachedTokens = 100; - private const int MaxTrackedSessions = 1000; // A fitted copy carries at most this many times MaxContentCharacters of content in all. private const int ContentBudgetFactor = 4; @@ -80,6 +79,10 @@ public sealed class DefenderRtpClient private readonly ConcurrentDictionary>> _inFlightTokens = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _sequences = new(StringComparer.Ordinal); + // The highest sequence given to a session that is no longer tracked; a session that comes back resumes above + // it, so its sequence keeps increasing. + private long _sequenceFloor; + /// Initializes a new instance of the class. /// The Defender configuration, for example . /// The HTTP client, for example from IHttpClientFactory; defaults to a shared client. @@ -482,23 +485,29 @@ private JsonObject PrepareEnvelope(JsonObject context, DefenderRtpAgentContext a /// /// Copies the valid tool declarations (a name, and a string description and object schema when present) - /// within the budget, the called tool's first so a short budget never drops it. Each declaration is charged - /// for its element and name as well as its content, so name-only declarations are bounded too. At a tool point - /// without declarations, the called tool is declared from the a365 extension. + /// within the budget, reading only as many entries as it can hold: each entry costs its element, and a copied + /// declaration its name and members too. A called tool whose declaration lies beyond them, or that has none, + /// is declared first from its name, with the a365 extension's description when there is one. /// private static void AddTools(JsonObject hook, JsonObject context, string? toolName, int maxCharacters, ContentBudget budget) { var cut = false; var tools = new JsonArray(); - var declared = (context["tools"] as JsonArray ?? new JsonArray()).OfType() - .Where(tool => ReadString(tool["name"]) is { Length: > 0 }); - - // One scan finds the called tool; the rest keep their order, and copying stops when the budget runs out. - var calledTool = toolName == null ? null : declared.FirstOrDefault(tool => ReadString(tool["name"]) == toolName); - var ordered = calledTool == null ? declared : declared.Where(tool => !ReferenceEquals(tool, calledTool)).Prepend(calledTool); - foreach (var tool in ordered) + var calledToolDeclared = false; + foreach (var entry in context["tools"] as JsonArray ?? new JsonArray()) { - var declaredName = ReadString(tool["name"])!; + if (budget.Remaining == 0) + { + break; + } + + if (entry is not JsonObject tool || ReadString(tool["name"]) is not { Length: > 0 } declaredName) + { + // An entry Defender would reject is skipped, but reading it still costs its element. + budget.Spend(1); + continue; + } + var cost = 1 + MemberCost("name", declaredName); if (budget.Remaining < cost) { @@ -517,10 +526,11 @@ private static void AddTools(JsonObject hook, JsonObject context, string? toolNa AddMember(declaration, "schema", schema, maxCharacters, budget, ref cut); } + calledToolDeclared |= declaredName == toolName; tools.Add(declaration); } - if (tools.Count == 0 && toolName != null) + if (toolName != null && !calledToolDeclared) { var declaration = new JsonObject { ["name"] = toolName }; // An extension namespace may hold any JSON value, so each level's shape is checked before it is read. @@ -532,7 +542,7 @@ private static void AddTools(JsonObject hook, JsonObject context, string? toolNa declaration["description"] = clamped; } - tools.Add(declaration); + tools.Insert(0, declaration); } if (tools.Count > 0) @@ -595,7 +605,10 @@ private static void AddMessages(JsonObject hook, JsonObject context, int maxChar } } - /// Copies the extension namespaces Defender accepts, within the budget. + /// + /// Copies the extension namespaces Defender accepts within the budget, reading only as many as it can hold: a + /// namespace Defender would reject is skipped but still costs a character. + /// private static void AddExtensions(JsonObject hook, JsonObject context, int maxCharacters, ContentBudget budget) { if (context["extensions"] is not JsonObject extensions) @@ -605,8 +618,19 @@ private static void AddExtensions(JsonObject hook, JsonObject context, int maxCh var cut = false; var kept = new JsonObject(); - foreach (var property in extensions.Where(property => ExtensionKeyPattern.IsMatch(property.Key))) + foreach (var property in extensions) { + if (!ExtensionKeyPattern.IsMatch(property.Key)) + { + if (budget.Remaining == 0) + { + break; + } + + budget.Spend(1); + continue; + } + if (!AddMember(kept, property.Key, property.Value, maxCharacters, budget, ref cut)) { break; @@ -906,11 +930,7 @@ private async Task GetAccessTokenAsync( { // Refresh early in the background and keep using the cached token until it actually // expires, as MSAL and Azure.Identity do; a failed refresh is retried by a later call. - _ = StartOrJoinAcquisition(key, agent, tokenResolver, scope).ContinueWith( - static task => { _ = task.Exception; }, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + ObserveFailure(StartOrJoinAcquisition(key, agent, tokenResolver, scope)); } return cached.Token; @@ -929,9 +949,20 @@ private async Task GetAccessTokenAsync( /// The number of cached tokens; for tests. internal int CachedTokenCount => _tokens.Count; + /// How many sessions' sequences are tracked; settable for tests, so eviction needs few sessions. + internal int MaxTrackedSessions { get; init; } = 1000; + private static string TokenKey(DefenderRtpAgentContext agent, string scope) => string.Join(":", agent.TenantId, agent.AgentId, scope); + /// Observes the failure of a task no caller awaits, so it is not reported as unobserved. + private static void ObserveFailure(Task task) => + _ = task.ContinueWith( + static completed => { _ = completed.Exception; }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + /// /// One acquisition per agent, tenant and scope, shared by concurrent callers. It is bounded by the /// configured timeout rather than by any caller's cancellation, and it leaves the in-flight map as it @@ -984,7 +1015,20 @@ private async Task ResolveAndCacheTokenAsync( string scope) { using var timeout = new CancellationTokenSource(Options.Timeout, _timeProvider); - var token = await tokenResolver(agent.AgentId, agent.TenantId, new[] { scope }, timeout.Token).ConfigureAwait(false); + var resolving = tokenResolver(agent.AgentId, agent.TenantId, new[] { scope }, timeout.Token); + string? token; + try + { + // The timeout bounds the resolver's task as well as asking it to stop, so a resolver that ignores + // cancellation cannot hold the identity's acquisition, and every later evaluation, forever. + token = await resolving.WaitAsync(timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + ObserveFailure(resolving); + throw; + } + if (string.IsNullOrWhiteSpace(token)) { throw new InvalidOperationException("The Defender token resolver returned no token."); @@ -1066,18 +1110,38 @@ private void CacheToken(string key, CachedToken token) private long NextSequence(string sessionId) { - var next = _sequences.AddOrUpdate(sessionId, 1, (_, current) => current + 1); + // A session the client does not track starts above every sequence given to a session it dropped, so a + // session that comes back never repeats or goes back. + var next = _sequences.AddOrUpdate(sessionId, _ => Interlocked.Read(ref _sequenceFloor) + 1, (_, current) => current + 1); if (_sequences.Count > MaxTrackedSessions) { - foreach (var key in _sequences.Keys.Where(key => key != sessionId).Take(_sequences.Count - MaxTrackedSessions).ToList()) + foreach (var entry in _sequences.Where(entry => entry.Key != sessionId).Take(_sequences.Count - MaxTrackedSessions).ToList()) { - _sequences.TryRemove(key, out _); + // The floor rises before the session is dropped, and the session is dropped only if it was not + // numbered since, so a concurrent call for it cannot fall below the floor. + RaiseSequenceFloor(entry.Value); + _sequences.TryRemove(entry); } } return next; } + private void RaiseSequenceFloor(long sequence) + { + var floor = Interlocked.Read(ref _sequenceFloor); + while (floor < sequence) + { + var observed = Interlocked.CompareExchange(ref _sequenceFloor, sequence, floor); + if (observed == floor) + { + return; + } + + floor = observed; + } + } + private string GeneratedToolCallId() => "tooluse_" + _idFactory().ToString("N").Substring(0, 12); private string UtcTimestamp(JsonNode? value) diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index d3f8acf6..b166f238 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -42,7 +42,8 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// Register it on an emitter whose per-interceptor timeout is longer than /// , such as one from ; /// a shorter timeout turns a slow evaluation into a fail-closed agent-hooks host error. The evaluation - /// callback runs once the verdict is decided and never changes it: an exception it throws is logged and ignored. + /// callback runs on the thread pool once the verdict is decided, outside that timeout, and never changes the + /// verdict: an exception it throws is logged and ignored. /// /// public sealed class A365DefenderInterceptor : IInterceptor @@ -70,9 +71,10 @@ public sealed class A365DefenderInterceptor : IInterceptor /// . /// /// - /// Receives each evaluation once its verdict is decided, for logging and telemetry (for example the - /// correlation id). It never changes the verdict: an exception it throws is logged to - /// , when given, and ignored. + /// Receives each evaluation for logging and telemetry (for example the correlation id). It runs on the thread + /// pool once the verdict is decided, so it may run after the interceptor returns and alongside later + /// evaluations. It never changes the verdict: an exception it throws is logged to , + /// when given, and ignored. /// /// Receives failures of the evaluation and of . public A365DefenderInterceptor( @@ -120,9 +122,14 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio return new Verdict(Decision.Allow); } - // The verdict is decided before the callback sees the result, so nothing the callback does changes it. + // The verdict is decided before the callback sees the result, and the callback runs on the thread pool, + // off the emitter's timed interception, so neither what it does nor how long it takes changes the verdict. var verdict = ToVerdict(result); - NotifyEvaluated(result); + if (_onEvaluated is { } onEvaluated) + { + _ = Task.Run(() => NotifyEvaluated(onEvaluated, result)); + } + return verdict; } @@ -130,16 +137,11 @@ public async ValueTask InterceptAsync(AgentContext context, Cancellatio /// Hands the evaluation to the host's callback. The callback is for logging and telemetry, so its /// failure is logged at most and never changes the verdict, in either fail mode. /// - private void NotifyEvaluated(DefenderRtpEvaluationResult result) + private void NotifyEvaluated(Action onEvaluated, DefenderRtpEvaluationResult result) { - if (_onEvaluated == null) - { - return; - } - try { - _onEvaluated(result); + onEvaluated(result); } catch (Exception ex) when (ex is not OutOfMemoryException) { diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 15e8e428..dcd3f6d1 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -85,10 +85,11 @@ var record = await emitter.EmitUncheckedAsync(builder.Input(userMessage), cancel if (!record.Proceeds) { /* blocked: record.Verdict.Message */ } ``` -The evaluation callback is for logging and telemetry. It runs once the verdict is decided and never changes it: an -exception it throws is logged (to the optional `ILogger` passed to `A365DefenderInterceptor`) and ignored. When -resolving the identity or evaluating throws, the result and verdict record only the exception's type; the exception -itself goes to the same logger. +The evaluation callback is for logging and telemetry. It runs on the thread pool once the verdict is decided, outside +the emitter's interceptor timeout, so neither what it does nor how long it takes changes the verdict; it may run +after the interceptor returns and alongside later evaluations. An exception it throws is logged (to the optional +`ILogger` passed to `A365DefenderInterceptor`) and ignored. When resolving the identity or evaluating throws, the +result and verdict record only the exception's type; the exception itself goes to the same logger. Agents built on Microsoft Agent Framework can register the same interceptor with `Microsoft.Agents.AI.AgentHooks`, which mediates model and tool calls. @@ -117,7 +118,8 @@ truncated before it is sent, and the request as a whole carries at most four tim under decision at the point (the input, the tool call's arguments, the tool result or the reply) first, then tool declarations, the newest message history, extensions and other members, with the oldest messages dropped first. Names, keys and nulls count toward that too, so a context padded with many empty or null items cannot inflate the -request. Content nested more than 32 levels deep is cut the same way. +request, and only as many tool declarations as the budget can hold are read: a called tool beyond them is declared +from its name. Content nested more than 32 levels deep is cut the same way. The agent's own context is not modified. When the content under decision was cut, Defender has not seen all of it: its deny still blocks, but its allow does not cover the rest, so the result follows the fail mode. Fail open allows with a `defender:unverified` warning; fail closed denies with `runtime_error:defender_unverified`. Raise the From 07c0003567797f59c0cb93b19b633249340a6b05 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 16:08:04 +0100 Subject: [PATCH 12/16] Dispose the slow-callback test's event after the callback finishes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../A365DefenderInterceptorTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs index b780fc63..9e6ad655 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AgentHooks.Tests/A365DefenderInterceptorTests.cs @@ -182,11 +182,16 @@ public async Task KeepsTheVerdictWhenTheEvaluationCallbackThrows(string decision [Fact] public async Task DoesNotLetASlowEvaluationCallbackDecideTheVerdict() { - var release = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var callbackDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var harness = new Harness( _ => Json(new { decision = "allow" }), timeout: TimeSpan.FromMilliseconds(500), - onEvaluated: _ => release.Wait(TimeSpan.FromSeconds(30)), + onEvaluated: _ => + { + release.Wait(TimeSpan.FromSeconds(30)); + callbackDone.TrySetResult(); + }, tokenResolver: async (_, _, _, _) => { // Asynchronous like a real call, so the emitter is already timing the interceptor when the callback runs. @@ -197,6 +202,7 @@ public async Task DoesNotLetASlowEvaluationCallbackDecideTheVerdict() var record = await harness.Emitter.EmitUncheckedAsync(builder.Input(JsonValue.Create("hello")!), CancellationToken.None); release.Set(); + await callbackDone.Task.WaitAsync(TimeSpan.FromSeconds(10)); record.Proceeds.Should().BeTrue("the callback runs after the verdict, outside the emitter's interceptor timeout"); record.Verdict.Decision.Should().Be(Decision.Allow); From 6e028405a6becba90e881ba945e0c5ef7c82917c Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 16:21:29 +0100 Subject: [PATCH 13/16] Copy the called tool's declaration first and mark it unverified when cut At a tool point, a name-only scan of the first 10,000 tool declarations finds the called tool's declaration, which is copied first, ahead of the rest of the context. If the scan stops before finding it, or its description or schema had to be cut, the result is marked Truncated, so Defender's allow follows the fail mode: it did not see the full declaration of the tool under decision. A tool absent from a list scanned to the end is declared from its name and is not a cut. The other declarations then fill the remaining budget in the host's order. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- CHANGELOG.md | 6 +- .../Protection/DefenderRtpClientTests.cs | 66 +++++++++-- .../Protection/Defender/DefenderRtpClient.cs | 109 +++++++++++------- .../Defender/DefenderRtpEvaluationResult.cs | 9 +- .../Protection/Defender/DefenderRtpOptions.cs | 5 +- .../AgentHooks/A365DefenderInterceptor.cs | 3 +- src/Tooling/Extensions/AgentHooks/README.md | 17 +-- 7 files changed, 148 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96466922..b331d665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,9 +85,9 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. - Every call sends a unique `x-ms-correlation-id`. Failures follow `A365_DEFENDER_RTP_FAIL_MODE` (`open` or `closed`; any other value is rejected), and a `400` reports the failed validation rule. Each string is clamped to `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS`, and the copy carries at most four times that in all: the content - under decision first, the oldest history dropped first. When the content under decision had to be cut, - Defender's deny stands and its allow follows the fail mode (`DefenderRtpEvaluationResult.Truncated`). Lone - surrogates are sent as U+FFFD. + under decision first, the oldest history dropped first. When the content under decision, or at a tool point the + called tool's declaration, had to be cut, Defender's deny stands and its allow follows the fail mode + (`DefenderRtpEvaluationResult.Truncated`). Lone surrogates are sent as U+FFFD. - **Microsoft.Agents.A365.Tooling.Extensions.AgentHooks** (new, preview) - `A365DefenderInterceptor`, an agent-hooks interceptor (`ResponsibleAI.AgentHooks` 0.1.0-beta.1) for Defender, and `A365AgentHooks.CreateProtectionEmitter` (`parallel/strictest`). When no agent identity is resolved, or resolving diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 1990c63f..0140239c 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -318,7 +318,8 @@ public async Task ClampsToolContentButNotTheToolIdentity() var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); result!.Truncated.Should().BeTrue("the tool result under decision was cut"); - body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{"query":"Seattle to"}}"""); + // The called tool's declaration comes before the arguments, which leaves them no room at this size. + body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{}}"""); body["tool_result"]!["value"]!.GetValue().Should().Be("three resu"); JsonNode.DeepEquals(body["target"], body["tool_result"]!["value"]).Should().BeTrue(); } @@ -402,6 +403,7 @@ public async Task KeepsTheCopyBoundedHoweverManyEmptyOrNullItemsTheContextHas(bo var context = ToolContext("pre_tool_call", "x", null); context["tools"] = new JsonArray(Enumerable.Range(0, Count) .Select(index => (JsonNode?)new JsonObject { ["name"] = $"t{index}" }) + .Prepend(new JsonObject { ["name"] = "FetchPage" }) .ToArray()); context["messages"] = new JsonArray(Enumerable.Range(0, Count) .Select(_ => (JsonNode?)new JsonObject { ["role"] = "user", ["content"] = new JsonObject() }) @@ -429,23 +431,73 @@ public async Task KeepsTheCopyBoundedHoweverManyEmptyOrNullItemsTheContextHas(bo } [Fact] - public async Task DeclaresTheCalledToolFromItsNameWhenItsDeclarationIsBeyondTheBudget() + public async Task CopiesTheCalledToolsDeclarationFirstWhateverPrecedesIt() { - var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 20 }); + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 40 }); var context = ToolContext("pre_tool_call", "x", null); context["tools"] = new JsonArray(Enumerable.Range(0, 4) .Select(index => (JsonNode?)new JsonObject { ["name"] = $"Other{index}", ["description"] = new string('d', 20) }) - .Append(new JsonObject { ["name"] = "FetchPage", ["description"] = "Reads a page." }) + .Append(new JsonObject { ["name"] = "FetchPage", ["description"] = "Reads a page.", ["schema"] = new JsonObject { ["type"] = "object" } }) .ToArray()); - await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + result!.Truncated.Should().BeFalse("Defender sees the called tool's whole declaration"); + result.Allowed.Should().BeTrue(); var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); + body["tools"]![0]!.ToJsonString().Should().Be("""{"name":"FetchPage","description":"Reads a page.","schema":{"type":"object"}}"""); body["tools"]!.AsArray().Select(tool => tool!["name"]!.GetValue()).Should().Equal( new[] { "FetchPage", "Other0", "Other1" }, - "only the entries the budget holds are read"); - body["tools"]![0]!.ToJsonString().Should().Be("""{"name":"FetchPage"}""", "a called tool beyond them is declared from its name"); + "the other declarations fill what remains in the host's order"); + } + + [Fact] + public async Task TreatsACalledToolBeyondTheScanAsTruncated() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { FailClosed = true }); + var context = ToolContext("pre_tool_call", "x", null); + context["tools"] = new JsonArray(Enumerable.Range(0, 10_000) + .Select(index => (JsonNode?)new JsonObject { ["name"] = $"t{index}" }) + .Append(new JsonObject { ["name"] = "FetchPage", ["description"] = "Reads a page." }) + .ToArray()); + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + result!.Evaluated.Should().BeTrue(); + result.Truncated.Should().BeTrue("the scan stopped before the called tool's declaration"); + result.Allowed.Should().BeFalse("a fail-closed client does not let an allow it cannot verify through"); + handler.Calls.Single().Body["tools"]![0]!.ToJsonString().Should().Be("""{"name":"FetchPage"}"""); + } + + [Fact] + public async Task TreatsACalledToolWhoseDeclarationIsCutAsTruncated() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 100, FailClosed = true }); + var context = ToolContext("pre_tool_call", "x", null); + context["tools"] = new JsonArray(new JsonObject { ["name"] = "FetchPage", ["description"] = new string('d', 150) }); + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + result!.Truncated.Should().BeTrue("Defender saw only part of the called tool's description"); + result.Allowed.Should().BeFalse(); + handler.Calls.Single().Body["tools"]![0]!["description"]!.GetValue().Should().EndWith("...[truncated 74 chars]"); + } + + [Fact] + public async Task DeclaresACalledToolAbsentFromTheListFromItsName() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" })); + var context = ToolContext("pre_tool_call", "x", null); + context["tools"] = new JsonArray(Enumerable.Range(0, 100).Select(index => (JsonNode?)new JsonObject { ["name"] = $"t{index}" }).ToArray()); + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + result!.Truncated.Should().BeFalse("a tool missing from a list read to the end is not a cut"); + result.Allowed.Should().BeTrue(); + var tools = handler.Calls.Single().Body["tools"]!.AsArray(); + tools[0]!.ToJsonString().Should().Be("""{"name":"FetchPage"}"""); + tools.Should().HaveCount(101); } [Fact] diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index 1afd521d..ba97faa1 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -53,6 +53,9 @@ public sealed class DefenderRtpClient // Content nested deeper than this is cut like content over the size limit, so the copy stays within common // JSON parser depth limits and the walk over the host's JSON stays shallow however deep it is. private const int MaxContentDepth = 32; + + // How many tool declarations a name-only scan reads to find the called tool's. + private const int MaxCalledToolScan = 10_000; private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromMinutes(5); private static readonly HashSet EvaluatedPoints = new(StringComparer.Ordinal) { "input", "pre_tool_call", "post_tool_call", "output" }; private static readonly HashSet ActorKinds = new(StringComparer.Ordinal) { "human", "service", "agent" }; @@ -114,9 +117,10 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => /// Evaluates an agent-hooks context with Defender. The context is not modified. /// /// - /// When the content under decision is longer than , - /// Defender evaluates a truncated copy: its deny stands, but an allow does not cover the content that was - /// cut, so the result is marked and follows + /// When the content under decision is longer than , or at + /// a tool point the called tool's declaration had to be cut or was not among the first 10,000 declarations, + /// Defender evaluates a truncated copy: its deny stands, but an allow does not cover what it did not see, so + /// the result is marked and follows /// . /// /// The agent-hooks/0.1 context emitted by the host. @@ -260,12 +264,12 @@ public DefenderRtpEvaluationResult Unavailable( /// /// Builds the copy of the context Defender evaluates without cloning the host's content. The envelope is /// rebuilt from its spec fields so the request meets Defender's validation; the content under decision at the - /// point (the field target mirrors) is clamped first, and then the tool call's arguments at - /// post_tool_call, tool declarations, the newest message history, extensions and any other member - /// share what remains of times - /// . Lone surrogates are replaced, since Defender cannot - /// parse them. is set when the content under decision was cut, so Defender - /// cannot see all of what its verdict would authorize. + /// point (the field target mirrors) is clamped first, then the called tool's declaration at a tool + /// point, and then the tool call's arguments at post_tool_call, the other tool declarations, the newest + /// message history, extensions and any other member share what remains of + /// times . Lone surrogates are replaced, since Defender + /// cannot parse them. is set when the content under decision or the called tool's + /// declaration was cut, so Defender cannot see all of what its verdict would authorize. /// private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, out bool truncated) { @@ -343,6 +347,12 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou budget.Spend(2 * decisionBudget.Used); + // At a tool point the called tool's declaration comes next, ahead of the rest of the context: Defender + // evaluates the call against it, so a declaration it cannot see in full leaves the verdict unverified, like + // cut content under decision. + var tools = new JsonArray(); + var calledToolCut = toolName != null && AddCalledTool(tools, context, toolName, maxCharacters, budget); + // The rest of the context. What is cut here does not change the authority of the verdict. var contextCut = false; if (point == "post_tool_call" && hook["tool_call"] is JsonObject calledTool) @@ -350,7 +360,12 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou calledTool["args"] = ToArguments((context["tool_call"] as JsonObject)?["args"], maxCharacters, budget, ref contextCut); } - AddTools(hook, context, toolName, maxCharacters, budget); + AddOtherTools(tools, context, toolName, maxCharacters, budget); + if (tools.Count > 0) + { + hook["tools"] = tools; + } + AddMessages(hook, context, maxCharacters, budget); AddExtensions(hook, context, maxCharacters, budget); foreach (var property in context.Where(property => !KnownMembers.Contains(property.Key))) @@ -361,7 +376,7 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou } } - truncated = decisionTruncated; + truncated = decisionTruncated || calledToolCut; return (JsonObject)ReplaceLoneSurrogates(hook)!; } @@ -484,16 +499,44 @@ private JsonObject PrepareEnvelope(JsonObject context, DefenderRtpAgentContext a } /// - /// Copies the valid tool declarations (a name, and a string description and object schema when present) - /// within the budget, reading only as many entries as it can hold: each entry costs its element, and a copied - /// declaration its name and members too. A called tool whose declaration lies beyond them, or that has none, - /// is declared first from its name, with the a365 extension's description when there is one. + /// Declares the called tool, from its declaration in tools when a name-only scan of the first + /// entries finds it, and otherwise from its name (with the a365 + /// extension's description when there is one). Returns true when Defender cannot see the tool's full + /// declaration: the scan stopped short of the end of the list without finding it, or its description or schema + /// had to be cut. A tool absent from a list scanned to the end is declared from its name and is not cut. /// - private static void AddTools(JsonObject hook, JsonObject context, string? toolName, int maxCharacters, ContentBudget budget) + private static bool AddCalledTool(JsonArray tools, JsonObject context, string toolName, int maxCharacters, ContentBudget budget) + { + var declared = context["tools"] as JsonArray; + var found = declared?.Take(MaxCalledToolScan).OfType().FirstOrDefault(tool => ReadString(tool["name"]) == toolName); + + // An extension namespace may hold any JSON value, so each level's shape is checked before it is read. + var source = found ?? (((context["extensions"] as JsonObject)?[A365Extension] as JsonObject)?["tool"] as JsonObject); + var cut = false; + var declaration = new JsonObject { ["name"] = toolName }; + budget.Spend(1 + MemberCost("name", toolName)); + if (source?["description"] is JsonValue description && ReadString(description) != null) + { + AddMember(declaration, "description", description, maxCharacters, budget, ref cut); + } + + if (found?["schema"] is JsonObject schema) + { + AddMember(declaration, "schema", schema, maxCharacters, budget, ref cut); + } + + tools.Add(declaration); + return cut || (found == null && declared?.Count > MaxCalledToolScan); + } + + /// + /// Fills what remains of the budget with the other valid tool declarations (a name, and a string description + /// and object schema when present) in the host's order, reading only as many entries as the budget can hold: + /// each entry costs its element, and a copied declaration its name and members too. + /// + private static void AddOtherTools(JsonArray tools, JsonObject context, string? toolName, int maxCharacters, ContentBudget budget) { var cut = false; - var tools = new JsonArray(); - var calledToolDeclared = false; foreach (var entry in context["tools"] as JsonArray ?? new JsonArray()) { if (budget.Remaining == 0) @@ -501,9 +544,10 @@ private static void AddTools(JsonObject hook, JsonObject context, string? toolNa break; } - if (entry is not JsonObject tool || ReadString(tool["name"]) is not { Length: > 0 } declaredName) + // An entry Defender would reject, or the called tool's, which is declared already, is skipped, but + // reading it still costs its element. + if (entry is not JsonObject tool || ReadString(tool["name"]) is not { Length: > 0 } declaredName || declaredName == toolName) { - // An entry Defender would reject is skipped, but reading it still costs its element. budget.Spend(1); continue; } @@ -526,29 +570,8 @@ private static void AddTools(JsonObject hook, JsonObject context, string? toolNa AddMember(declaration, "schema", schema, maxCharacters, budget, ref cut); } - calledToolDeclared |= declaredName == toolName; tools.Add(declaration); } - - if (toolName != null && !calledToolDeclared) - { - var declaration = new JsonObject { ["name"] = toolName }; - // An extension namespace may hold any JSON value, so each level's shape is checked before it is read. - if ((context["extensions"] as JsonObject)?[A365Extension] is JsonObject a365 - && a365["tool"] is JsonObject calledToolExtension - && calledToolExtension["description"] is JsonValue description - && ReadString(Clamp(description, maxCharacters, budget, ref cut)) is { Length: > 0 } clamped) - { - declaration["description"] = clamped; - } - - tools.Insert(0, declaration); - } - - if (tools.Count > 0) - { - hook["tools"] = tools; - } } /// @@ -673,8 +696,8 @@ private static JsonObject ToArguments(JsonNode? args, int maxCharacters, Content /// /// Posts the context; is the evaluation's one deadline, and - /// says whether the content under decision was cut to fit - /// . + /// says whether the content under decision, or the called tool's declaration, was + /// cut, so Defender's allow does not cover all of it. /// private async Task PostAsync( JsonObject hook, diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs index c58cb840..e2427afa 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs @@ -47,10 +47,11 @@ public sealed class DefenderRtpEvaluationResult public bool Evaluated { get; init; } /// - /// Whether the content under decision was longer than , - /// so Defender evaluated a truncated copy. Its deny stands; an allow does not cover the content that was cut, - /// so follows and is - /// . + /// Whether Defender evaluated a truncated copy of what is under decision: the content under decision was longer + /// than allowed, or at a tool point the called tool's + /// declaration had to be cut or was not among the first 10,000 declarations. Its deny stands; an allow does not + /// cover what Defender did not see, so follows + /// and is . /// public bool Truncated { get; init; } diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index b72d054a..631c58e5 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -49,8 +49,9 @@ public sealed class DefenderRtpOptions /// carries at most four times this many characters of content: the content under decision first, then the /// rest of the context, with the oldest messages dropped first. Names, keys and nulls count toward it too, and /// content nested more than 32 levels deep is cut the same way. - /// When the content under decision is cut, Defender evaluates a truncated copy and only its deny stands; an - /// allow follows . Raise it for agents with long content. + /// When the content under decision, or at a tool point the called tool's declaration, is cut, Defender + /// evaluates a truncated copy and only its deny stands; an allow follows . Raise it for + /// agents with long content or long tool declarations. /// public int MaxContentCharacters { get; set; } = 20000; diff --git a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs index b166f238..dfb2b649 100644 --- a/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs +++ b/src/Tooling/Extensions/AgentHooks/A365DefenderInterceptor.cs @@ -34,7 +34,8 @@ public sealed record A365DefenderCall(DefenderRtpAgentContext Agent, DefenderRtp /// /// /// When no verdict is obtained (no agent identity, identity resolution, transport, authentication or validation - /// failure), or Defender allowed a copy truncated to , the + /// failure), or Defender allowed a truncated copy of what is under decision + /// (), the /// verdict follows : allow with a defender:unverified warning, /// or deny with reason runtime_error:defender_unverified, which is never reported as a detection. /// diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index dcd3f6d1..c1fd4f6e 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -115,13 +115,16 @@ if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` **Content size.** Each content string longer than `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` (default 20000) is truncated before it is sent, and the request as a whole carries at most four times that much content: the content -under decision at the point (the input, the tool call's arguments, the tool result or the reply) first, then tool -declarations, the newest message history, extensions and other members, with the oldest messages dropped first. -Names, keys and nulls count toward that too, so a context padded with many empty or null items cannot inflate the -request, and only as many tool declarations as the budget can hold are read: a called tool beyond them is declared -from its name. Content nested more than 32 levels deep is cut the same way. -The agent's own context is not modified. When the content under decision was cut, Defender has not seen all of -it: its deny still blocks, but its allow does not cover the rest, so the result follows the fail mode. Fail open +under decision at the point (the input, the tool call's arguments, the tool result or the reply) first, then at a +tool point the called tool's declaration, then the other tool declarations, the newest message history, extensions +and other members, with the oldest messages dropped first. Names, keys and nulls count toward that too, so a context +padded with many empty or null items cannot inflate the request, and only as many tool declarations as the budget +can hold are read. The called tool's declaration is found with a name-only scan of the first 10,000 declarations; a +tool missing from a list scanned to the end is declared from its name. Content nested more than 32 levels deep is +cut the same way. +The agent's own context is not modified. When the content under decision, or the called tool's declaration, was cut +(or the scan stopped before finding the declaration), Defender has not seen all of it: its deny still blocks, but its +allow does not cover the rest, so the result follows the fail mode. Fail open allows with a `defender:unverified` warning; fail closed denies with `runtime_error:defender_unverified`. Raise the limit for agents that handle long content. Evaluating long content in chunks is a planned follow-up. Lone UTF-16 surrogates in any string are replaced with U+FFFD, since Defender cannot parse them. From 6ab7f61bb16a85ae700280359e8137a40104398d Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 16:37:39 +0100 Subject: [PATCH 14/16] Never follow redirects with Defender credentials, and key tokens by parts - The shared HTTP client no longer follows redirects: a 307 or 308 would replay the agent's context, or the token exchange's client assertion, to another host. A 3xx is a failure that follows the fail mode, and a request an injected client redirected anyway is treated as a failure. - Key cached and in-flight tokens by tenant, agent and scope as separate parts, so identities whose ids would join alike never share a token. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../Protection/DefenderRtpClientTests.cs | 35 ++++++++++++++ .../DefenderRtpTokenResolversTests.cs | 39 +++++++++++++++ .../Protection/Defender/DefenderRtpClient.cs | 48 ++++++++++++++----- .../Defender/DefenderRtpTokenResolvers.cs | 16 +++++-- src/Tooling/Extensions/AgentHooks/README.md | 4 ++ 5 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 0140239c..d5534586 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -1067,6 +1067,41 @@ public async Task ReleasesAnAcquisitionWhoseResolverIgnoresTheDeadline() handler.Calls.Should().ContainSingle(); } + [Fact] + public async Task KeepsTheTokensOfIdentitiesWhoseIdsWouldJoinAlike() + { + var (client, handler, _) = Create(_ => Json(new { decision = "allow" })); + var first = new TokenSource(); + var second = new TokenSource(); + + await client.EvaluateHookContextAsync(InputContext("one"), new DefenderRtpAgentContext { TenantId = "a", AgentId = "b:c" }, first.Resolve); + await client.EvaluateHookContextAsync(InputContext("two"), new DefenderRtpAgentContext { TenantId = "a:b", AgentId = "c" }, second.Resolve); + + second.Requests.Should().ContainSingle("another identity's token is never reused"); + handler.Calls[1].Authorization.Should().Be($"Bearer {second.Token}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TreatsARedirectedRequestAsNoVerdict(bool failClosed) + { + var (client, _, tokens) = Create( + request => + { + // What a client that follows redirects does: the request ends at another host, which answers. + request.RequestUri = new Uri("https://elsewhere.example.test/v1/protection/evaluate"); + return Json(new { decision = "allow" }); + }, + new DefenderRtpOptions { FailClosed = failClosed }); + + var result = await client.EvaluateHookContextAsync(InputContext("hello"), Agent, tokens.Resolve); + + result!.Evaluated.Should().BeFalse(); + result.Allowed.Should().Be(!failClosed); + result.Error.Should().Be("request was redirected"); + } + // ─── options ───────────────────────────────────────────────────────────── [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs index 2c9f6580..8196242c 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpTokenResolversTests.cs @@ -98,6 +98,45 @@ public async Task FailsOnAnErrorStatusWithoutEchoingTheResponse() failure.Which.Message.Should().Contain("HTTP 401").And.NotContain(Assertion).And.NotContain("invalid_client"); } + [Fact] + public async Task FailsWhenTheTokenRequestIsRedirected() + { + // What a client that follows redirects does: the request ends at another host, which answers. + var endpoint = new TokenEndpoint(request => + { + request.RequestUri = new Uri("https://elsewhere.example.test/token"); + return Json("""{"access_token":"defender-token"}"""); + }); + using var httpClient = new HttpClient(endpoint); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, httpClient); + + var act = () => resolver(AgentId, TenantId, Scopes, CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*redirected*"); + } + + [Fact] + public async Task FailsOnARedirectItDoesNotFollow() + { + var endpoint = new TokenEndpoint(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.TemporaryRedirect); + response.Headers.Location = new Uri("https://elsewhere.example.test/token"); + return response; + }); + using var httpClient = new HttpClient(endpoint); + var resolver = DefenderRtpTokenResolvers.FromAgenticConnection(Connection(Assertion).Object, httpClient); + + var act = () => resolver(AgentId, TenantId, Scopes, CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*HTTP 307*"); + endpoint.Requests.Should().ContainSingle("the assertion is sent only to the token endpoint"); + } + + [Fact] + public void UsesASharedClientThatDoesNotFollowRedirects() => + DefenderRtpClient.SharedHandler.AllowAutoRedirect.Should().BeFalse(); + [Theory] [InlineData("not json", "*not valid JSON*")] [InlineData("""{"token_type":"Bearer"}""", "*no access_token*")] diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index ba97faa1..e7b517ed 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -69,17 +69,25 @@ public sealed class DefenderRtpClient "trace", "input", "output", "target", "tool_call", "tool_result", "messages", "tools", "extensions", }; - // Used when the caller passes no HttpClient; pooled connections are recycled so DNS changes are picked up. - internal static readonly HttpClient SharedHttpClient = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }); + // Used when the caller passes no HttpClient. Pooled connections are recycled so DNS changes are picked up, and + // redirects are never followed: a 307 or 308 would replay the token, the client assertion or the agent's + // content to another host. + internal static readonly SocketsHttpHandler SharedHandler = new() + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + AllowAutoRedirect = false, + }; + + internal static readonly HttpClient SharedHttpClient = new(SharedHandler); private readonly HttpClient _httpClient; private readonly Func _idFactory; private readonly TimeProvider _timeProvider; - private readonly ConcurrentDictionary _tokens = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _tokens = new(); // Serializes writes to _tokens, so concurrent acquisitions cannot push it past MaxCachedTokens. private readonly object _tokenCacheLock = new(); - private readonly ConcurrentDictionary>> _inFlightTokens = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary>> _inFlightTokens = new(); private readonly ConcurrentDictionary _sequences = new(StringComparer.Ordinal); // The highest sequence given to a session that is no longer tracked; a session that comes back resumes above @@ -88,7 +96,11 @@ public sealed class DefenderRtpClient /// Initializes a new instance of the class. /// The Defender configuration, for example . - /// The HTTP client, for example from IHttpClientFactory; defaults to a shared client. + /// + /// The HTTP client, for example from IHttpClientFactory; defaults to a shared client that does not + /// follow redirects. A client passed here must not follow them either: a redirected request is treated as a + /// failure, since the context has been sent to another host. + /// /// Creates correlation ids (tests). /// The clock (tests). public DefenderRtpClient( @@ -745,6 +757,13 @@ private async Task PostAsync( using (response) { var status = (int)response.StatusCode; + + // A client that follows redirects has sent the context to another host; what came back is no verdict. + if (request.RequestUri != endpoint) + { + return Failure(point, correlationId, sessionId, "request was redirected", status, started); + } + string body; try { @@ -975,8 +994,8 @@ private async Task GetAccessTokenAsync( /// How many sessions' sequences are tracked; settable for tests, so eviction needs few sessions. internal int MaxTrackedSessions { get; init; } = 1000; - private static string TokenKey(DefenderRtpAgentContext agent, string scope) => - string.Join(":", agent.TenantId, agent.AgentId, scope); + private static TokenCacheKey TokenKey(DefenderRtpAgentContext agent, string scope) => + new(agent.TenantId, agent.AgentId, scope); /// Observes the failure of a task no caller awaits, so it is not reported as unobserved. private static void ObserveFailure(Task task) => @@ -992,7 +1011,7 @@ private static void ObserveFailure(Task task) => /// completes, so a cancelled caller cannot strand it and a failure is never handed to a later caller. /// private Task StartOrJoinAcquisition( - string key, + TokenCacheKey key, DefenderRtpAgentContext agent, DefenderRtpTokenResolver tokenResolver, string scope) @@ -1014,7 +1033,7 @@ private Task StartOrJoinAcquisition( } private async Task AcquireTokenAsync( - string key, + TokenCacheKey key, Lazy> acquisition, DefenderRtpAgentContext agent, DefenderRtpTokenResolver tokenResolver, @@ -1027,12 +1046,12 @@ private async Task AcquireTokenAsync( finally { // Runs before the acquisition's task completes, so no caller joins a finished or failed one. - _inFlightTokens.TryRemove(new KeyValuePair>>(key, acquisition)); + _inFlightTokens.TryRemove(new KeyValuePair>>(key, acquisition)); } } private async Task ResolveAndCacheTokenAsync( - string key, + TokenCacheKey key, DefenderRtpAgentContext agent, DefenderRtpTokenResolver tokenResolver, string scope) @@ -1075,7 +1094,7 @@ private async Task ResolveAndCacheTokenAsync( /// closest to expiry. Writers are serialized, so concurrent acquisitions cannot each evict the same entry and /// then all insert. /// - private void CacheToken(string key, CachedToken token) + private void CacheToken(TokenCacheKey key, CachedToken token) { lock (_tokenCacheLock) { @@ -1087,7 +1106,7 @@ private void CacheToken(string key, CachedToken token) _tokens.TryRemove(stale, out _); } - if (_tokens.Count >= MaxCachedTokens && _tokens.OrderBy(entry => entry.Value.ExpiresAt).FirstOrDefault() is { Key: not null } oldest) + if (_tokens.Count >= MaxCachedTokens && _tokens.OrderBy(entry => entry.Value.ExpiresAt).FirstOrDefault() is { Value: not null } oldest) { _tokens.TryRemove(oldest.Key, out _); } @@ -1381,6 +1400,9 @@ private static void RequireString(string? value, string name) private sealed record CachedToken(string Token, DateTimeOffset ExpiresAt); + // Identifies an agent identity's token by its parts, so no two identities can share a key. + private readonly record struct TokenCacheKey(string TenantId, string AgentId, string Scope); + /// The characters of content a fitted copy may still carry. private sealed class ContentBudget { diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs index 715ee9de..8e092c19 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpTokenResolvers.cs @@ -28,7 +28,9 @@ public static class DefenderRtpTokenResolvers /// (MSAL connections implement it). /// /// - /// The HTTP client for the token endpoint, for example from IHttpClientFactory; defaults to a shared client. + /// The HTTP client for the token endpoint, for example from IHttpClientFactory; defaults to a shared + /// client that does not follow redirects. A client passed here must not follow them either, since a 307 or 308 + /// would replay the client assertion to another host; a redirected request fails. /// /// /// The Entra authority, an absolute HTTPS URL; defaults to https://login.microsoftonline.com. @@ -61,9 +63,8 @@ public static DefenderRtpTokenResolver FromAgenticConnection( throw new InvalidOperationException("The agent connection returned no agent identity assertion."); } - using var request = new HttpRequestMessage( - HttpMethod.Post, - $"{baseAuthority}/{Uri.EscapeDataString(tenantId)}/oauth2/v2.0/token") + var tokenUri = new Uri($"{baseAuthority}/{Uri.EscapeDataString(tenantId)}/oauth2/v2.0/token"); + using var request = new HttpRequestMessage(HttpMethod.Post, tokenUri) { Content = new FormUrlEncodedContent(new Dictionary { @@ -75,6 +76,13 @@ public static DefenderRtpTokenResolver FromAgenticConnection( }), }; using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // A client that follows redirects has sent the assertion to another host; a token from there is never used. + if (request.RequestUri != tokenUri) + { + throw new InvalidOperationException("The Defender token request was redirected."); + } + // Never surface the response body: it can echo the assertion. if (!response.IsSuccessStatusCode) { diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index c1fd4f6e..428fdaaf 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -85,6 +85,10 @@ var record = await emitter.EmitUncheckedAsync(builder.Input(userMessage), cancel if (!record.Proceeds) { /* blocked: record.Verdict.Message */ } ``` +`httpClient` is optional for both the client and the token resolver; without it they share a client that does not +follow redirects. A client you pass must not follow them either (for example `AllowAutoRedirect = false`): a 307 or 308 +would replay the context, or the client assertion, to another host, so a redirected request is treated as a failure. + The evaluation callback is for logging and telemetry. It runs on the thread pool once the verdict is decided, outside the emitter's interceptor timeout, so neither what it does nor how long it takes changes the verdict; it may run after the interceptor returns and alongside later evaluations. An exception it throws is logged (to the optional From f21a0b25e479c30f92e6fee4adcc01787910bcd3 Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 16:52:55 +0100 Subject: [PATCH 15/16] Match the called-tool rule across the SDKs - The called tool's name is its identity: it is copied whole and not charged; its description and schema use the budget. - The other declarations skip only the called tool's entry. - The a365 extension's description declares the called tool only when there are no tool declarations at all; a cut there is a truncation. - Each truncation cause has its own Error: TruncatedContentError, TruncatedToolDeclarationError (the declaration was cut) and UnseenToolDeclarationError (the list is longer than 10,000 and the called tool was not among the first 10,000). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- .../Protection/DefenderRtpClientTests.cs | 46 +++++++-- .../Protection/Defender/DefenderRtpClient.cs | 99 ++++++++++++------- .../Defender/DefenderRtpEvaluationResult.cs | 4 +- src/Tooling/Extensions/AgentHooks/README.md | 26 ++--- 4 files changed, 124 insertions(+), 51 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index d5534586..1fb2a9ef 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -318,8 +318,7 @@ public async Task ClampsToolContentButNotTheToolIdentity() var body = handler.Calls.Single().Body; DefenderContract.Errors(body).Should().BeEmpty(); result!.Truncated.Should().BeTrue("the tool result under decision was cut"); - // The called tool's declaration comes before the arguments, which leaves them no room at this size. - body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{}}"""); + body["tool_call"]!.ToJsonString().Should().Be("""{"id":"call_42","name":"SearchFlights","args":{"query":"Seattle to"}}"""); body["tool_result"]!["value"]!.GetValue().Should().Be("three resu"); JsonNode.DeepEquals(body["target"], body["tool_result"]!["value"]).Should().BeTrue(); } @@ -448,7 +447,7 @@ public async Task CopiesTheCalledToolsDeclarationFirstWhateverPrecedesIt() DefenderContract.Errors(body).Should().BeEmpty(); body["tools"]![0]!.ToJsonString().Should().Be("""{"name":"FetchPage","description":"Reads a page.","schema":{"type":"object"}}"""); body["tools"]!.AsArray().Select(tool => tool!["name"]!.GetValue()).Should().Equal( - new[] { "FetchPage", "Other0", "Other1" }, + new[] { "FetchPage", "Other0", "Other1", "Other2" }, "the other declarations fill what remains in the host's order"); } @@ -467,9 +466,25 @@ public async Task TreatsACalledToolBeyondTheScanAsTruncated() result!.Evaluated.Should().BeTrue(); result.Truncated.Should().BeTrue("the scan stopped before the called tool's declaration"); result.Allowed.Should().BeFalse("a fail-closed client does not let an allow it cannot verify through"); + result.Error.Should().Be(DefenderRtpClient.UnseenToolDeclarationError); handler.Calls.Single().Body["tools"]![0]!.ToJsonString().Should().Be("""{"name":"FetchPage"}"""); } + [Theory] + [InlineData(10_000, false)] + [InlineData(10_001, true)] + public async Task TreatsAMissingCalledToolAsTruncatedOnlyWhenTheListOutrunsTheScan(int count, bool truncated) + { + var (client, _, tokens) = Create(_ => Json(new { decision = "allow" })); + var context = ToolContext("pre_tool_call", "x", null); + context["tools"] = new JsonArray(Enumerable.Range(0, count).Select(index => (JsonNode?)new JsonObject { ["name"] = $"t{index}" }).ToArray()); + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + result!.Truncated.Should().Be(truncated); + result.Error.Should().Be(truncated ? DefenderRtpClient.UnseenToolDeclarationError : null); + } + [Fact] public async Task TreatsACalledToolWhoseDeclarationIsCutAsTruncated() { @@ -481,23 +496,42 @@ public async Task TreatsACalledToolWhoseDeclarationIsCutAsTruncated() result!.Truncated.Should().BeTrue("Defender saw only part of the called tool's description"); result.Allowed.Should().BeFalse(); + result.Error.Should().Be(DefenderRtpClient.TruncatedToolDeclarationError); handler.Calls.Single().Body["tools"]![0]!["description"]!.GetValue().Should().EndWith("...[truncated 74 chars]"); } + [Fact] + public async Task TreatsACutA365DescriptionAsTruncatedWhenNoToolsAreDeclared() + { + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 100, FailClosed = true }); + var context = ToolContext("pre_tool_call", "x", null); + context["extensions"] = new JsonObject { ["a365"] = new JsonObject { ["tool"] = new JsonObject { ["description"] = new string('d', 150) } } }; + + var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + result!.Truncated.Should().BeTrue("without declarations the a365 description is the called tool's, and it was cut"); + result.Allowed.Should().BeFalse(); + result.Error.Should().Be(DefenderRtpClient.TruncatedToolDeclarationError); + handler.Calls.Single().Body["tools"]![0]!["name"]!.GetValue().Should().Be("FetchPage"); + } + [Fact] public async Task DeclaresACalledToolAbsentFromTheListFromItsName() { - var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" })); + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" }), new DefenderRtpOptions { MaxContentCharacters = 100 }); var context = ToolContext("pre_tool_call", "x", null); context["tools"] = new JsonArray(Enumerable.Range(0, 100).Select(index => (JsonNode?)new JsonObject { ["name"] = $"t{index}" }).ToArray()); + context["extensions"] = new JsonObject { ["a365"] = new JsonObject { ["tool"] = new JsonObject { ["description"] = new string('d', 150) } } }; var result = await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); - result!.Truncated.Should().BeFalse("a tool missing from a list read to the end is not a cut"); + result!.Truncated.Should().BeFalse("a tool missing from a list read to the end is not a cut, and the a365 description is used only without declarations"); result.Allowed.Should().BeTrue(); var tools = handler.Calls.Single().Body["tools"]!.AsArray(); tools[0]!.ToJsonString().Should().Be("""{"name":"FetchPage"}"""); - tools.Should().HaveCount(101); + tools.Skip(1).Take(2).Select(tool => tool!["name"]!.GetValue()).Should().Equal( + new[] { "t0", "t1" }, + "the other declarations follow in the host's order"); } [Fact] diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index e7b517ed..be796f01 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -42,6 +42,22 @@ public sealed class DefenderRtpClient /// public const string TruncatedContentError = "content exceeded MaxContentCharacters; Defender evaluated a truncated copy"; + /// + /// The of a result that Defender allowed at a tool point after + /// the called tool's description or schema had to be cut: Defender evaluated a truncated copy, so the result + /// follows . + /// + public const string TruncatedToolDeclarationError = + "the called tool's declaration exceeded MaxContentCharacters; Defender evaluated a truncated copy"; + + /// + /// The of a result that Defender allowed at a tool point when + /// the list of tool declarations is longer than 10,000 and the called tool was not among the first 10,000: + /// Defender evaluated without its declaration, so the result follows . + /// + public const string UnseenToolDeclarationError = + "the called tool was not among the first 10000 tool declarations; Defender evaluated without its declaration"; + private const string DefaultFramework = "agent365"; private const string A365Extension = "a365"; private const int MaxErrorDetailCharacters = 200; @@ -174,7 +190,7 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => RequireString(agent.AgentId, nameof(agent.AgentId)); RequireString(agent.TenantId, nameof(agent.TenantId)); - var hook = Prepare(context, agent, out var truncated); + var hook = Prepare(context, agent, out var truncation); var sessionId = ReadString((hook["session"] as JsonObject)?["id"]); var started = _timeProvider.GetTimestamp(); @@ -204,7 +220,7 @@ public static bool IsEvaluatedInterceptionPoint(string? interceptionPoint) => return Unavailable(point!, tokenError, sessionId, null, _timeProvider.GetElapsedTime(started)); } - return await PostAsync(hook, point!, sessionId, token!, truncated, started, deadline.Token, cancellationToken).ConfigureAwait(false); + return await PostAsync(hook, point!, sessionId, token!, truncation, started, deadline.Token, cancellationToken).ConfigureAwait(false); } /// @@ -280,10 +296,11 @@ public DefenderRtpEvaluationResult Unavailable( /// point, and then the tool call's arguments at post_tool_call, the other tool declarations, the newest /// message history, extensions and any other member share what remains of /// times . Lone surrogates are replaced, since Defender - /// cannot parse them. is set when the content under decision or the called tool's - /// declaration was cut, so Defender cannot see all of what its verdict would authorize. + /// cannot parse them. says why Defender cannot see all of what its verdict would + /// authorize (the content under decision or the called tool's declaration was cut, or the declaration was not + /// among those scanned), or is null when it can. /// - private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, out bool truncated) + private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, out string? truncation) { var maxCharacters = Options.MaxContentCharacters; var budget = new ContentBudget((int)Math.Min(int.MaxValue, (long)ContentBudgetFactor * maxCharacters)); @@ -363,7 +380,10 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou // evaluates the call against it, so a declaration it cannot see in full leaves the verdict unverified, like // cut content under decision. var tools = new JsonArray(); - var calledToolCut = toolName != null && AddCalledTool(tools, context, toolName, maxCharacters, budget); + JsonObject? calledToolDeclaration = null; + var calledToolTruncation = toolName == null + ? null + : AddCalledTool(tools, context, toolName, maxCharacters, budget, out calledToolDeclaration); // The rest of the context. What is cut here does not change the authority of the verdict. var contextCut = false; @@ -372,7 +392,7 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou calledTool["args"] = ToArguments((context["tool_call"] as JsonObject)?["args"], maxCharacters, budget, ref contextCut); } - AddOtherTools(tools, context, toolName, maxCharacters, budget); + AddOtherTools(tools, context, calledToolDeclaration, maxCharacters, budget); if (tools.Count > 0) { hook["tools"] = tools; @@ -388,7 +408,7 @@ private JsonObject Prepare(JsonObject context, DefenderRtpAgentContext agent, ou } } - truncated = decisionTruncated || calledToolCut; + truncation = decisionTruncated ? TruncatedContentError : calledToolTruncation; return (JsonObject)ReplaceLoneSurrogates(hook)!; } @@ -511,22 +531,29 @@ private JsonObject PrepareEnvelope(JsonObject context, DefenderRtpAgentContext a } /// - /// Declares the called tool, from its declaration in tools when a name-only scan of the first - /// entries finds it, and otherwise from its name (with the a365 - /// extension's description when there is one). Returns true when Defender cannot see the tool's full - /// declaration: the scan stopped short of the end of the list without finding it, or its description or schema - /// had to be cut. A tool absent from a list scanned to the end is declared from its name and is not cut. + /// Declares the called tool. A name-only scan of the first entries of + /// tools finds its declaration, whose description and schema use the budget; the name, the tool's + /// identity, is copied whole and not charged. Without declarations at all, the a365 extension's + /// description is used. Returns why Defender cannot see the tool's full declaration (its description or schema + /// had to be cut, or the list is longer than the scan and the tool was not among the entries scanned), or null. + /// A tool absent from a list scanned to the end is declared from its name, and that is not a cut. /// - private static bool AddCalledTool(JsonArray tools, JsonObject context, string toolName, int maxCharacters, ContentBudget budget) + private static string? AddCalledTool( + JsonArray tools, + JsonObject context, + string toolName, + int maxCharacters, + ContentBudget budget, + out JsonObject? found) { var declared = context["tools"] as JsonArray; - var found = declared?.Take(MaxCalledToolScan).OfType().FirstOrDefault(tool => ReadString(tool["name"]) == toolName); + found = declared?.Take(MaxCalledToolScan).OfType().FirstOrDefault(tool => ReadString(tool["name"]) == toolName); // An extension namespace may hold any JSON value, so each level's shape is checked before it is read. - var source = found ?? (((context["extensions"] as JsonObject)?[A365Extension] as JsonObject)?["tool"] as JsonObject); + var source = found + ?? (declared is { Count: > 0 } ? null : ((context["extensions"] as JsonObject)?[A365Extension] as JsonObject)?["tool"] as JsonObject); var cut = false; var declaration = new JsonObject { ["name"] = toolName }; - budget.Spend(1 + MemberCost("name", toolName)); if (source?["description"] is JsonValue description && ReadString(description) != null) { AddMember(declaration, "description", description, maxCharacters, budget, ref cut); @@ -538,15 +565,21 @@ private static bool AddCalledTool(JsonArray tools, JsonObject context, string to } tools.Add(declaration); - return cut || (found == null && declared?.Count > MaxCalledToolScan); + if (cut) + { + return TruncatedToolDeclarationError; + } + + return found == null && declared?.Count > MaxCalledToolScan ? UnseenToolDeclarationError : null; } /// /// Fills what remains of the budget with the other valid tool declarations (a name, and a string description - /// and object schema when present) in the host's order, reading only as many entries as the budget can hold: - /// each entry costs its element, and a copied declaration its name and members too. + /// and object schema when present) in the host's order, skipping the called tool's, which is declared already. + /// Only as many entries as the budget can hold are read: each entry costs its element, and a copied declaration + /// its name and members too. /// - private static void AddOtherTools(JsonArray tools, JsonObject context, string? toolName, int maxCharacters, ContentBudget budget) + private static void AddOtherTools(JsonArray tools, JsonObject context, JsonObject? calledTool, int maxCharacters, ContentBudget budget) { var cut = false; foreach (var entry in context["tools"] as JsonArray ?? new JsonArray()) @@ -556,9 +589,9 @@ private static void AddOtherTools(JsonArray tools, JsonObject context, string? t break; } - // An entry Defender would reject, or the called tool's, which is declared already, is skipped, but - // reading it still costs its element. - if (entry is not JsonObject tool || ReadString(tool["name"]) is not { Length: > 0 } declaredName || declaredName == toolName) + // The called tool's entry, or one Defender would reject, is skipped, but reading it still costs its + // element. + if (ReferenceEquals(entry, calledTool) || entry is not JsonObject tool || ReadString(tool["name"]) is not { Length: > 0 } declaredName) { budget.Spend(1); continue; @@ -708,15 +741,15 @@ private static JsonObject ToArguments(JsonNode? args, int maxCharacters, Content /// /// Posts the context; is the evaluation's one deadline, and - /// says whether the content under decision, or the called tool's declaration, was - /// cut, so Defender's allow does not cover all of it. + /// says why Defender's allow would not cover all of what is under decision, or + /// is null when it would. /// private async Task PostAsync( JsonObject hook, string point, string? sessionId, string accessToken, - bool truncated, + string? truncation, long started, CancellationToken deadline, CancellationToken cancellationToken) @@ -801,10 +834,10 @@ private async Task PostAsync( } var allowed = verdict.Decision == "allow"; - if (truncated && allowed) + if (truncation != null && allowed) { - // Defender saw only a truncated copy, so its allow does not cover the content that was cut and - // follows the fail mode instead. A deny, or a transform (applied here as a block), stands. + // Defender saw only a truncated copy, so its allow does not cover what it did not see and follows + // the fail mode instead. A deny, or a transform (applied here as a block), stands. return new DefenderRtpEvaluationResult { Allowed = !Options.FailClosed, @@ -815,10 +848,10 @@ private async Task PostAsync( SessionId = sessionId, Verdict = verdict, HttpStatus = status, - Error = TruncatedContentError, + Error = truncation, Latency = _timeProvider.GetElapsedTime(started), BlockReason = Options.FailClosed - ? "The content is longer than Defender evaluates and this agent is configured to fail closed." + ? "Defender could not evaluate all of the content and this agent is configured to fail closed." : null, }; } @@ -827,7 +860,7 @@ private async Task PostAsync( { Allowed = allowed, Evaluated = true, - Truncated = truncated, + Truncated = truncation != null, InterceptionPoint = point, CorrelationId = correlationId, SessionId = sessionId, diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs index e2427afa..04984914 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpEvaluationResult.cs @@ -51,7 +51,9 @@ public sealed class DefenderRtpEvaluationResult /// than allowed, or at a tool point the called tool's /// declaration had to be cut or was not among the first 10,000 declarations. Its deny stands; an allow does not /// cover what Defender did not see, so follows - /// and is . + /// and says which: , + /// or + /// . /// public bool Truncated { get; init; } diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 428fdaaf..1333185c 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -118,17 +118,21 @@ if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` | `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` | clamps each content string sent; ids, names and roles are not truncated (default 20000). See **Content size** | **Content size.** Each content string longer than `A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS` (default 20000) is -truncated before it is sent, and the request as a whole carries at most four times that much content: the content -under decision at the point (the input, the tool call's arguments, the tool result or the reply) first, then at a -tool point the called tool's declaration, then the other tool declarations, the newest message history, extensions -and other members, with the oldest messages dropped first. Names, keys and nulls count toward that too, so a context -padded with many empty or null items cannot inflate the request, and only as many tool declarations as the budget -can hold are read. The called tool's declaration is found with a name-only scan of the first 10,000 declarations; a -tool missing from a list scanned to the end is declared from its name. Content nested more than 32 levels deep is -cut the same way. -The agent's own context is not modified. When the content under decision, or the called tool's declaration, was cut -(or the scan stopped before finding the declaration), Defender has not seen all of it: its deny still blocks, but its -allow does not cover the rest, so the result follows the fail mode. Fail open +truncated before it is sent, and the request as a whole carries at most four times that much content, in this order: +the content under decision at the point (the input, the tool call's arguments, the tool result or the reply); at a +tool point the called tool's declaration; the tool call's arguments at `post_tool_call`; the other tool declarations +in the agent's order; the message history, newest first; then extensions and other members. Names, keys and nulls +count toward that too, so a context padded with many empty or null items cannot inflate the request, and only as +many tool declarations as the budget can hold are read. Content nested more than 32 levels deep is cut the same way. + +The called tool's declaration is found with a name-only scan of the first 10,000 declarations; its name is sent whole, +and its description and schema count toward the limit. Without any declarations, the `a365` extension's description +of the tool is used. A tool missing from a list scanned to the end is declared from its name. + +The agent's own context is not modified. When the content under decision or the called tool's declaration was cut, +or the list is longer than the scan and the called tool was not among the declarations scanned, Defender has not +seen all of it: its deny still blocks, but its allow does not cover the rest, so the result follows the fail mode and +`Error` says which (`TruncatedContentError`, `TruncatedToolDeclarationError` or `UnseenToolDeclarationError`). Fail open allows with a `defender:unverified` warning; fail closed denies with `runtime_error:defender_unverified`. Raise the limit for agents that handle long content. Evaluating long content in chunks is a planned follow-up. Lone UTF-16 surrogates in any string are replaced with U+FFFD, since Defender cannot parse them. From 578854f82337e14a8f04860be1329b6843e51cfa Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Thu, 8 Oct 2026 17:08:00 +0100 Subject: [PATCH 16/16] Reject unknown enable values and keep collision renaming linear - ENABLE_A365_DEFENDER_RTP accepts true or false (and 1/0, yes/no, on/off); any other value fails startup instead of quietly leaving protection off, as FromEnvironment documents. - Keys renamed after replacing lone surrogates remember their next suffix per name, so many keys colliding on one name cost linear work rather than quadratic. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fdebe82a-4ddd-45f5-9970-ee89289ca92b --- docs/design.md | 2 +- .../Protection/DefenderRtpClientTests.cs | 52 +++++++++++++++++++ .../Protection/Defender/DefenderRtpClient.cs | 15 ++++-- .../Protection/Defender/DefenderRtpOptions.cs | 38 +++++++++++--- src/Tooling/Extensions/AgentHooks/README.md | 2 +- 5 files changed, 95 insertions(+), 14 deletions(-) diff --git a/docs/design.md b/docs/design.md index 063166e7..75060c6d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -337,7 +337,7 @@ else if (EnvironmentUtils.IsDevelopmentEnvironment()) | `EnableAgent365Exporter` | Enable Agent365 telemetry exporter | `false` | | `ASPNETCORE_ENVIRONMENT` | Environment name (Development, Production) | - | | `SuppressInvokeAgentInput` | Suppress input messages in invoke_agent spans | `false` | -| `ENABLE_A365_DEFENDER_RTP` | Enable Microsoft Defender for AI real-time protection (`DefenderRtpOptions.FromEnvironment`) | `false` | +| `ENABLE_A365_DEFENDER_RTP` | Enable Microsoft Defender for AI real-time protection (`DefenderRtpOptions.FromEnvironment`); a value that is not true or false is rejected | `false` | | `A365_DEFENDER_RTP_ENDPOINT` | Defender prevention endpoint, `https:///v1/protection/evaluate` | - | | `A365_DEFENDER_RTP_FAIL_MODE` | `open` or `closed` (blocks when Defender returns no verdict); any other value is rejected | open | diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs index 1fb2a9ef..054acc9c 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/Protection/DefenderRtpClientTests.cs @@ -608,6 +608,28 @@ public async Task KeepsEveryValueWhenReplacingLoneSurrogatesInKeysWouldCollide() body["tool_call"]!["args"]!.ToJsonString().Should().Be(new JsonObject { ["q\uFFFD~2"] = Payload, ["q\uFFFD"] = "harmless" }.ToJsonString()); } + [Fact] + public async Task KeepsEveryValueWhenManyKeysCollideOnOneName() + { + const int Count = 1024; + var (client, handler, tokens) = Create(_ => Json(new { decision = "allow" })); + var context = ToolContext("pre_tool_call", "x", null); + var args = new JsonObject(); + for (var index = 0; index < Count; index++) + { + args["q" + (char)(0xD800 + index)] = $"v{index}"; + } + + context["tool_call"]!["args"] = args; + + await client.EvaluateHookContextAsync(context, Agent, tokens.Resolve); + + var sent = handler.Calls.Single().Body["tool_call"]!["args"]!.AsObject(); + sent.Select(property => property.Value!.GetValue()).Should().Equal(Enumerable.Range(0, Count).Select(index => $"v{index}")); + sent.Select(property => property.Key).Should().Equal( + new[] { "q\uFFFD" }.Concat(Enumerable.Range(2, Count - 1).Select(suffix => $"q\uFFFD~{suffix}"))); + } + [Fact] public async Task RejectsAContextWithoutAnAgentId() { @@ -1181,6 +1203,36 @@ public void RejectsAnEndpointThatIsNotAbsoluteHttps(string endpoint) fromEnvironment.Should().Throw().WithMessage("*absolute HTTPS URL*"); } + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("1", true)] + [InlineData("yes", true)] + [InlineData("on", true)] + [InlineData("false", false)] + [InlineData("0", false)] + [InlineData("no", false)] + [InlineData("off", false)] + public void ReadsWhetherDefenderIsEnabled(string? value, bool enabled) + { + var options = DefenderRtpOptions.FromEnvironment(name => name == "ENABLE_A365_DEFENDER_RTP" ? value : null); + + options.Enabled.Should().Be(enabled); + } + + [Theory] + [InlineData("ture")] + [InlineData("enabled")] + [InlineData("2")] + public void RejectsAnUnknownEnabledValueRatherThanTurningProtectionOff(string value) + { + var act = () => DefenderRtpOptions.FromEnvironment(name => name == "ENABLE_A365_DEFENDER_RTP" ? value : null); + + act.Should().Throw().WithMessage("*ENABLE_A365_DEFENDER_RTP*"); + } + [Theory] [InlineData(null, false)] [InlineData("", false)] diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs index be796f01..89480123 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpClient.cs @@ -1328,22 +1328,27 @@ private static string SanitizeFramework(string? framework) return items; case JsonObject obj: - // Keys that are valid UTF-16 keep their names; only the renamed ones can need a suffix. + // Keys that are valid UTF-16 keep their names; only the renamed ones can need a suffix. Each name + // remembers its next suffix, so many keys that collide on it cost linear, not quadratic, work. var taken = new HashSet( obj.Select(property => property.Key).Where(key => ReplaceLoneSurrogates(key) == key), StringComparer.Ordinal); + var nextSuffix = new Dictionary(StringComparer.Ordinal); var properties = new JsonObject(); foreach (var property in obj) { var name = ReplaceLoneSurrogates(property.Key); - if (name != property.Key) + if (name != property.Key && !taken.Add(name)) { - var unique = name; - for (var suffix = 2; !taken.Add(unique); suffix++) + var suffix = nextSuffix.TryGetValue(name, out var next) ? next : 2; + string unique; + do { - unique = $"{name}~{suffix}"; + unique = $"{name}~{suffix++}"; } + while (!taken.Add(unique)); + nextSuffix[name] = suffix; name = unique; } diff --git a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs index 631c58e5..8c117e6f 100644 --- a/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs +++ b/src/Tooling/Core/Protection/Defender/DefenderRtpOptions.cs @@ -5,6 +5,7 @@ namespace Microsoft.Agents.A365.Tooling.Protection.Defender { using System; using System.Globalization; + using System.Linq; /// /// Configuration for Microsoft Defender for AI real-time protection (the prevention endpoint @@ -18,7 +19,13 @@ public sealed class DefenderRtpOptions /// Default token scope: the Defender API. public const string DefaultAuthenticationScope = "api://" + DefenderApiAppId + "/.default"; - /// Whether Defender real-time protection is enabled (ENABLE_A365_DEFENDER_RTP). + private static readonly string[] EnabledValues = { "true", "1", "yes", "on" }; + private static readonly string[] DisabledValues = { "false", "0", "no", "off" }; + + /// + /// Whether Defender real-time protection is enabled (ENABLE_A365_DEFENDER_RTP: true or + /// false, or 1/0, yes/no, on/off; unset means disabled). + /// public bool Enabled { get; set; } /// @@ -62,8 +69,9 @@ public sealed class DefenderRtpOptions /// /// Reads the options from the given variable lookup. A value a variable does not accept fails rather than - /// falling back to a default; in particular, A365_DEFENDER_RTP_FAIL_MODE must be open or - /// closed, so a typo cannot quietly weaken enforcement. + /// falling back to a default, so a typo cannot quietly weaken protection: in particular, + /// ENABLE_A365_DEFENDER_RTP must say true or false and A365_DEFENDER_RTP_FAIL_MODE must be + /// open or closed. /// /// Returns the value of an environment variable, or null. /// The configured options. @@ -83,10 +91,7 @@ public static DefenderRtpOptions FromEnvironment(Func getVariab var options = new DefenderRtpOptions { - Enabled = Read("ENABLE_A365_DEFENDER_RTP") is { } enabled - && (enabled.Equals("true", StringComparison.OrdinalIgnoreCase) - || enabled == "1" - || enabled.Equals("yes", StringComparison.OrdinalIgnoreCase)), + Enabled = ParseEnabled(Read("ENABLE_A365_DEFENDER_RTP")), FailClosed = ParseFailMode(Read("A365_DEFENDER_RTP_FAIL_MODE")), }; @@ -157,6 +162,25 @@ private static int ParsePositive(string value, string name) => ? parsed : throw new InvalidOperationException($"{name} must be a positive integer."); + /// + /// Whether Defender RTP is enabled. Unset means disabled; a value that is not a recognized way of saying true + /// or false is rejected, so a typo cannot quietly turn protection off. + /// + private static bool ParseEnabled(string? value) + { + if (value == null || DisabledValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + if (EnabledValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + + throw new InvalidOperationException("ENABLE_A365_DEFENDER_RTP must be true or false."); + } + /// Whether the fail mode is closed. Unset means open; any value but open or closed is rejected. private static bool ParseFailMode(string? value) { diff --git a/src/Tooling/Extensions/AgentHooks/README.md b/src/Tooling/Extensions/AgentHooks/README.md index 1333185c..b9c593c0 100644 --- a/src/Tooling/Extensions/AgentHooks/README.md +++ b/src/Tooling/Extensions/AgentHooks/README.md @@ -110,7 +110,7 @@ if Defender is configured to fail open. `A365AgentHooks.CreateProtectionEmitter` | Variable | Meaning | |---|---| -| `ENABLE_A365_DEFENDER_RTP` | `true` to call Defender | +| `ENABLE_A365_DEFENDER_RTP` | `true` to call Defender; `false` or unset leaves it off (`1`/`0`, `yes`/`no` and `on`/`off` also work); any other value is rejected | | `A365_DEFENDER_RTP_ENDPOINT` | the prevention endpoint, an absolute HTTPS URL: `https:///v1/protection/evaluate` | | `A365_DEFENDER_RTP_FAIL_MODE` | `open` (default) or `closed`, which blocks when no verdict is obtained; any other value is rejected | | `A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS` | the deadline for one evaluation, including token acquisition (default 10000) |