You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add Microsoft Defender for AI real-time protection on agent-hooks - #310
Adds opt-in real-time protection from Microsoft Defender for AI ("Defender RTP") to the Agent 365 Node.js SDK, on the Responsible AI agent-hooks control contract (AGENT-HOOKS-0.1). The host emits an agent-hooks context at each interception point, an interceptor posts it to Defender's prevention endpoint, and Defender's verdict decides allow or deny. This is the Node.js part of a three-SDK change, with .NET (microsoft/Agent365-dotnet#266) and Python (microsoft/Agent365-python#278); the API shape, defaults and environment variables match.
@microsoft/agents-a365-tooling (no new dependency):
DefenderRtpClient: evaluateHookContext sends an agent-hooks/0.1 context to POST .../v1/protection/evaluate and returns a DefenderRtpEvaluationResult. prefetchAccessToken warms the token cache, isEvaluatedInterceptionPoint reports the four points Defender evaluates, and unavailable builds a not-evaluated result.
DefenderRtpTokenResolver and DefenderRtpTokenResolvers.fromAgenticConnection, which return the agent identity's app-only token.
DefenderRtpAgentContext, plus six Defender settings on ToolingConfiguration, read from the environment or from override functions.
@microsoft/agents-a365-tooling-extensions-agenthooks (new package, preview), the only package that uses @responsibleai/agent-hooks. It is a peer dependency (>=0.1.0-alpha.5 <0.2.0), so the application installs it alongside and both share one copy:
A365DefenderInterceptor, an agent-hooks Interceptor registered as defender, with an evaluation callback for logging; A365DefenderCall carries the identity and token resolver for each context.
createProtectionEmitter (enforce mode, parallel/strictest) and addA365Defender.
Tests, docs (package README and design docs, the tooling design doc, docs/design.md, CLAUDE.md), CHANGELOG, the package lists (jest mapper, dependency graph, build and release docs), and a named pnpm catalog (peers) for the peer range.
Scope: Defender RTP and the agent-hooks interceptor only. No Purview and no samples.
Supersedes #277, which targeted an older Defender endpoint and API. That PR is left open for its owner to close.
How it works
Interception points. Defender evaluates four points. Any other point (agent_startup, model calls, agent_shutdown) is allowed locally with no call, as is every point while Defender RTP is disabled.
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
Request. The body is a copy of the host's AgentContext, built field by field while reading it (the context is never serialized whole) and fitted to Defender's request validation; the host's context is never modified. The copy keeps the session, sequence and tool-call ids, and is fitted as follows:
spec is agent-hooks/0.1, timestamp is UTC, and sequence is ≥ 0.
agent.id is the Entra agent identity, agent.framework is sanitized to ^[a-z0-9_-]+$, and session.id is required.
target equals the point's field, tool_call/tool_result carry only spec members, and loosely filled optional fields are repaired or dropped. Only the active point's field is sent: an input, output, tool_call or tool_result left over from another point is left out, as in the .NET SDK.
Every optional node is shape-checked before it is read. One of another shape (for example a string model or actor, or an a365 extension that isn't an object) is left out rather than indexed, so it can't fail an evaluation. session and trace carry only their spec members of the right shape: id, a UTC started_at and a non-negative integer turn; string trace_id and span_id.
tenant carries only id, always the agent's tenant since it must equal the token's tid, and the host's name when the host's tenant id matches.
Every string and object key is well formed: a lone UTF-16 surrogate becomes U+FFFD (String.prototype.toWellFormed, with a fallback on Node.js 18). Otherwise JSON.stringify would write a \uD8xx escape that Defender's JSON parser rejects, and the request would fail, by default open. When two keys of one object become equal that way, only the first is sent; in the content under decision that makes the copy incomplete (see Long content).
Each content string is cut to at most A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS characters, truncation marker included, and nesting deeper than 32 levels is cut. Identifiers and protocol fields are sent unchanged.
Total budget. The copy carries at most 4 × A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS of content. Every copied element counts at least one character: strings, keys and other values count their length (so empty strings and nulls count too), each array, object, tool declaration and message counts one more, and tool declarations and messages count their keys. Only the called tool's name is copied whole without cost. A million name-only tools or empty messages are therefore trimmed like any other content. It's filled in this order:
the content under decision, up to half of the total, since target sends it twice;
at a tool call, the called tool's declaration (see Called tool);
the tool-call arguments at post_tool_call;
the other tool declarations, in host order;
the newest messages;
extensions, then any other fields.
Called tool. At pre_tool_call and post_tool_call, Defender decides with the called tool's declaration, so it comes first and is always present.
It is searched for by name only among the first 10,000 tools entries and copied first: its name whole, its description and schema within the budget. Otherwise it is declared by name, with the a365 extension's description.
The copy is incomplete (see Long content) when the called tool's description or schema had to be cut (by the string limit, the budget, the depth limit, or a key that doesn't fit), or when the list has more than 10,000 entries and the tool isn't among the first 10,000. A tool absent from a list of at most 10,000 isn't truncation. The two cases have their own errors: "the called tool's declaration exceeded A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS (N); Defender evaluated a truncated copy" and "the called tool was not among the first 10000 tool declarations; Defender evaluated without its declaration".
The other declarations follow in host order, skipping the called tool's entry, bounded by the budget.
Bounded reading. Lists and objects are read only as far as the budget reaches, so a huge context is never scanned whole:
the called tool is searched for among at most 10,000 declarations, and the others are read only as far as they could fit;
the history is read newest first and stops before a message without a role or content, which Defender would reject;
keys that cost nothing (extension namespaces Defender doesn't accept, values JSON leaves out) count toward what is read.
Sequence. When the host omits sequence, the client numbers each session's contexts itself, for the last 1000 sessions. A session seen again after that resumes above every number given to a dropped session, so sequence keeps increasing within session.id.
A request_id that isn't a string falls back to the agent's request id, like a missing one.
Correlation. Every call sends a unique x-ms-correlation-id (GUID), returned as DefenderRtpEvaluationResult.correlationId. Defender logs each evaluation under it.
Verdict.
allow proceeds, and Defender's warnings and result_labels carry through to the agent-hooks verdict. A warning reason in the host_error: namespace, which agent-hooks reserves for the host, becomes defender:warning, so it can't turn an allow into a host error.
deny blocks with Defender's message, and the agent-hooks verdict (defender:block:<reason>) carries the correlation id as evidence.
transform also blocks, because this version doesn't apply rewrites.
Response members of another shape (a transform that isn't an object, warnings or labels that aren't arrays) are ignored, so they never cost Defender its decision.
Long content. The content under decision (the message, the tool call arguments, the tool result or the reply) may not fit: a string can be longer than A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS, or the content can exceed its share of the total. Then Defender sees only a truncated copy. A block (deny or transform) of the copy stands, but an allow doesn't cover the rest: the result is marked truncated and follows the fail mode, like a missing verdict. Otherwise content padded past the limit would be authorized unseen. The same applies, with its own error, when two keys of the content under decision become one once made well formed, or when the called tool's declaration is incomplete.
Authentication. Defender is always called app-only as the agent identity. That covers user turns, autonomous runs, A2A and startup prefetch, where there is no user token.
The Agents SDK connection returns the agent identity's assertion through getAgenticApplicationToken(tenantId, agentAppInstanceId). This is the same authority Observability S2S export uses.
fromAgenticConnection exchanges the assertion at https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token:
grant_type=client_credentials;
client_id = the agent identity, with a jwt-bearer client assertion;
The token carries the app role RealtimeProtection.Evaluate.All.
Tokens are cached per tenant, agent and scope until 5 minutes before they expire.
There is a single in-flight acquisition per key, dropped when it completes, and failures are never cached.
In the last 5 minutes, evaluations keep using the still-valid token while it refreshes in the background, so a slow or failed refresh doesn't fail them.
The endpoint and the token authority must be https URLs with a host. Each is parsed once, and requests go to the parsed URL. Neither request follows a redirect, so the context, token and client assertion are never resent elsewhere; a redirect fails like any transport error.
Fail mode. A timeout, token or transport failure, non-2xx response, or any other send or read error marks the result not evaluated (evaluated: false, with error and httpStatus).
One deadline (A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS) covers the token acquisition and the request.
createProtectionEmitter gives the emitter a longer interceptor timeout and rejects one that isn't longer, so the client's fail mode applies before the emitter times out. The agent-hooks default of 5 s is below the client's 10 s, and the README tells hosts that build their own emitter to raise it. The timeout is fixed when the emitter is created, so with per-request Defender timeouts hosts create the emitter per turn or pass a larger timeout. Both timeouts must stay within Node's largest timer delay (2147483647 ms), since a longer timer fires after 1 ms: the Defender timeout is at most 2147481647, and the emitter rejects an interceptor timeout that isn't an integer in range.
Fail open (the default) allows the action with a defender:unverified warning.
A365_DEFENDER_RTP_FAIL_MODE=closed denies with runtime_error:defender_unverified, which is never reported as a detection.
A 400 reports the failed rules from Defender's diagnostics.validationErrors in error; diagnostics of another shape fall back to the title. A token whose payload has no numeric exp is used but not cached.
An invalid context or identity, or a call resolver that throws or resolves no agent identity, follows the fail mode too. The logging callback runs after the verdict is returned, off the interceptor's timed path, so a slow callback can't delay the action; errors it throws, and rejections of any thenable it returns (including a promise from another realm), are ignored.
Configuration
Variable
Meaning
ENABLE_A365_DEFENDER_RTP
true (or 1, yes, on) to call Defender; off by default (false, 0, no, off or unset); any other value fails at startup
A365_DEFENDER_RTP_ENDPOINT
the prevention endpoint, https://<host>/v1/protection/evaluate (required when enabled)
A365_DEFENDER_RTP_FAIL_MODE
closed blocks when no verdict is obtained; open (the default) allows; any other value is rejected, so a typo can't silently fail open
A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS
deadline of each evaluation, token included: a whole number (default 10000, at most 2147481647); a value such as 10s fails at startup instead of becoming 10 ms
A365_DEFENDER_RTP_AUTHENTICATION_SCOPE
overrides the Defender API scope
A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS
clamps each content string sent, a whole number (default 20000, at most 2147483647, as in the .NET and Python SDKs); the request carries at most four times as much content
The same settings are available as ToolingConfiguration override functions, for per-tenant or per-request configuration.
Installation.npm install @microsoft/agents-a365-tooling-extensions-agenthooks @responsibleai/agent-hooks@0.1.0-alpha.5. The application installs the agent-hooks peer itself, so the emitter, AgentContextBuilder and proceeds it imports come from the same copy the extension uses.
Prerequisites.
The agent identity has RealtimeProtection.Evaluate.All on the Defender API (86a21212-634e-4553-b3d6-e477e4c9d9ec).
Until then, a tenant administrator grants it once per agent blueprint (the app role on the blueprint's service principal, made inheritable), as the README describes. Every agent identity created from the blueprint inherits it.
The agent's tenant is onboarded to Microsoft Defender for AI.
Otherwise Defender answers 403, which follows the fail mode.
npm run lint, npm run build and npm test pass locally (the tests workspace: 69 suites, 1569 tests, including the 252 new tests below).
pnpm install --frozen-lockfile passes with pnpm 10.20.0, and pnpm pack publishes the peer range and no runtime dependency on agent-hooks.
138 DefenderRtpClient tests (tests/tooling/defender-rtp-client.test.ts). Each request body is checked against the endpoint's request validation rules (ported from the .NET tests). They cover:
the disabled client, and points Defender doesn't evaluate;
the correlation id, the agent identity, and no redirects;
tool-call and tool-result fitting, repair of loose optional fields, and clamping of each content string with identifiers left whole;
deny, allow with warnings and labels, and transform-as-block;
fail open and closed on HTTP errors, token failures and any other send or read error;
400 validation rules, a 2xx with no decision, non-JSON, timeouts, and caller cancellation;
the scope, token caching, the shared acquisition, early refresh, prefetch, and the shared deadline;
https-only endpoints, an unknown fail mode rejected at construction, and linear-time trimming of the framework;
content longer than the limit at all four points: fail closed denies, fail open allows and says why, a Defender deny or transform of the copy stands, content at the limit is evaluated normally, and truncation outside the content under decision doesn't count;
the total budget:
the history is trimmed newest-first while the content under decision stays whole;
content under decision cut by the total is reported as truncated;
the fill order;
a 2 MB tool result is bounded to an ~80 KB request;
nesting is cut at 32 levels;
shared values are copied, and a circular reference is rejected;
a million empty messages, messages with null content, or nulls, empty strings or empty objects elsewhere keep the request under 400 KB without counting as truncation, while the same in the content under decision is reported as truncated;
only as many tools and messages as the budget can hold are read (counted through a proxy over a million-entry list), the history stops before an invalid message, and extension namespaces, other fields and keys that JSON leaves out are read only as far as the budget reaches;
the called tool: copied first from a list padded with 9,999 other declarations; charged before large post_tool_call arguments; unverified at both tool points when it lies beyond the first 10,000, or when its description or schema is cut; without it, a list of 10,000 declarations is not truncation and one of 10,001 is; and the other declarations following in host order;
session and trace reduced to their spec members of the right shape;
a session's sequence keeps increasing after the session is no longer tracked;
lone surrogates in values and keys, using both the native toWellFormed and the Node 18 fallback: the raw body has no \uD8xx escapes, and a payload behind a lone surrogate is still evaluated and denied. Keys that become one in the content under decision make the copy incomplete (fail closed denies, fail open allows and says why); elsewhere they don't count;
other shapes:
a string model, actor, tenant, tools or messages, a request_id that isn't a string (which falls back to the agent's), and an a365 extension that is a string, has a string tool, or is an array;
a string session, which is rejected;
deny and transform with a transform of another shape;
warnings and labels of another shape;
400 diagnostics of another shape, and error bodies that aren't objects;
JWT payloads that aren't objects with a numeric exp (used, not cached);
the agent's tenant always sent, content strings never longer than the limit, and fields left over from another point (input, output, tool_call, tool_result) never sent at input, pre_tool_call or output.
21 DefenderRtpTokenResolvers.fromAgenticConnection tests: token URL and form fields, the assertion, Entra error codes without the body, non-2xx, malformed JSON, success responses of another shape (no or non-string access_token, an array, a string, null), error bodies of another shape, cancellation, an https-only authority, the token endpoint built from the parsed authority, and no redirects.
57 configuration tests covering defaults, the environment, overrides and validation, including ENABLE_A365_DEFENDER_RTP (true/false, 1/0, yes/no, on/off, and the rejection of anything else), open/closed in any case, the rejection of other fail modes, the upper bounds of the timeout and of the maximum content characters (2147483647: 308 nines, which would overflow the budget to Infinity, are rejected), and whole numbers for the timeout and the maximum content characters (10s, 1e4, 10.5, -5 and the like are rejected rather than read by parseInt; blank keeps the default).
36 A365DefenderInterceptor tests, run under the real agent-hooks native emitter. They cover:
forward and allow, a denied tool call, and warnings and labels on an allowed action;
verdict mapping with evidence;
fail open and fail closed when Defender is unavailable or slow;
an invalid identity, a throwing call resolver, a logging callback that throws or returns a rejecting thenable or a rejected promise from another realm, and a slow logging callback that runs only after the verdict is returned;
a Defender warning in the reserved host_error: namespace, remapped so the allow stands;
points Defender doesn't evaluate, the disabled client, and no resolved identity (which follows the fail mode);
20,000 harmless characters followed by a blocked payload: denied as unverified when failing closed, allowed with the warning when failing open, and Defender's deny or transform of truncated content kept as a block, even failing open;
a deny with a transform of another shape still blocks as a detection; a context whose session isn't an object follows the fail mode without throwing; optional members of another shape are left out;
the emitter timeout rules, including interceptor timeouts outside Node's timer range.
The new suites also pass on Node.js 18.20.8; the native module loads there despite the package's engines field (≥ 20).
Verified live against the Microsoft Defender for AI prevention endpoint, with an agent-hooks emitter and A365DefenderInterceptor running fail-closed on Node.js 18 and 24.
It authenticated as an agent identity through fromAgenticConnection: an app-only token with RealtimeProtection.Evaluate.All.
Clean traffic was allowed at all four points.
A known-malicious URL in the tool arguments at pre_tool_call was denied (defender:block:prevention_blocked, with Defender's threat label on the verdict), and the tool did not run.
Padded input and a ~500 KB tool result were each accepted and evaluated on their truncated copies, then denied as unverified under fail-closed.
A body that had held a lone surrogate was accepted after normalization and evaluated.
Known limitations and follow-ups
@responsibleai/agent-hooks is a peer dependency, tested with 0.1.0-alpha.5.
The range, >=0.1.0-alpha.5 <0.2.0, admits 0.1.0-beta.1. Development, tests and the lockfile pin 0.1.0-alpha.5.
It's a prerelease with a native core for linux-x64/arm64 (glibc), darwin-x64/arm64 and win32-x64 (no musl), and it needs Node.js 20+, so the new package declares engines.node >=20.
The .NET SDK uses 0.1.0-beta.1. Testing against beta.1 and moving the pin, with its lockfile update, is a follow-up.
Long content follows the fail mode. This applies when the content under decision is longer than A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS, or more than its share of the 4× total, and Defender doesn't block the truncated copy.
Long base64 or multimodal content under decision is affected too, so agents that handle long content should raise the limit.
Evaluating long content in chunks is a follow-up.
The budget counts more than string content. Keys and non-string values count toward the total. Nesting is cut at 32 levels, well under common JSON parser depth limits.
transform is treated as a block. Rewrites aren't applied; transformPath is surfaced on the verdict.
Not yet wired into the hosting pipeline. Hosts create the emitter and emit contexts themselves. Framework adapters (for example LangChain or OpenAI Agents SDK middleware) are follow-ups.
Tooling (@microsoft/agents-a365-tooling), no new dependency:
- DefenderRtpClient.evaluateHookContext sends a copy of an agent-hooks/0.1
context, fitted to Defender's request validation, 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. Every content string is
clamped; identifiers and protocol fields are sent unchanged.
- Calls carry the agent identity's app-only token for the Defender API
(RealtimeProtection.Evaluate.All), resolved by a DefenderRtpTokenResolver;
DefenderRtpTokenResolvers.fromAgenticConnection uses the agent's Agents SDK
connection, the authority Observability S2S uses. Tokens are cached per
agent, tenant and scope with one shared acquisition, refreshed ahead of
expiry without failing evaluations, and never cached on failure.
- A unique x-ms-correlation-id per call, one deadline for the token and the
request, and https-only endpoint and authority. Failures follow
A365_DEFENDER_RTP_FAIL_MODE; a 400 reports the failed validation rules.
- ToolingConfiguration settings: ENABLE_A365_DEFENDER_RTP and
A365_DEFENDER_RTP_{ENDPOINT,FAIL_MODE,TIMEOUT_MILLISECONDS,
AUTHENTICATION_SCOPE,MAX_CONTENT_CHARACTERS}.
New preview package @microsoft/agents-a365-tooling-extensions-agenthooks:
- A365DefenderInterceptor, an agent-hooks interceptor for
@responsibleai/agent-hooks (pinned to 0.1.0-alpha.5), with an evaluation
callback; createProtectionEmitter (enforce, parallel/strictest, interceptor
timeout above the Defender timeout) and addA365Defender.
Tests: 56 client, 7 token resolver, 6 configuration and 19 interceptor tests
(under the real native emitter), with request bodies checked against the
endpoint's request validation rules.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
CodeQL (js/polynomial-redos) flagged the trailing-run patterns that trim
hyphens from a sanitized agent.framework and slashes from the token
authority. Both now scan characters in linear time.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
This snapshots a potentially per-request timeout when the emitter is created. Configuration override functions are explicitly dynamic, and the README permits a shared emitter with per-request configuration; if a later request returns a larger Defender timeout, the emitter's fixed timeout fires first and denies instead of applying the configured fail mode. Require callers using dynamic providers to supply a timeout above the maximum possible value, or create the emitter from each effective configuration.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
Until a365 setup grants it, a tenant administrator grants
RealtimeProtection.Evaluate.All once per agent blueprint (app role on the
blueprint's service principal, made inheritable), and every agent identity
created from the blueprint inherits it. Replaces the per-agent-identity
example, which does not scale.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
This samples the Defender timeout only when the emitter is created, although configuration override functions are explicitly per-request (DefaultConfigurationProvider.ts:17-30). With a shared emitter, a tenant can later resolve a timeout larger than this static emitter timeout, causing agent-hooks to deny with host_error:interceptor_timeout before the client's configured fail mode runs. Require an explicit emitter timeout above the maximum dynamic value, or otherwise make the guarantee hold for per-request timeout overrides.
Declare agent-hooks as a tests workspace dependency
This test workspace imports @responsibleai/agent-hooks directly, but tests/package.json does not declare it; only the sibling extension package does. Under pnpm's isolated dependency linking, this relies on incidental hoisting and can fail module resolution in a clean install. Add the catalog dependency to the tests workspace and update the lockfile.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
- A call resolver that resolves no agent identity no longer allows the
context silently: Defender is not called and the context follows the
fail mode, reported through the evaluation callback.
- tenant.id is always the agent's tenant, which Defender requires to match
the token's tenant; a different host tenant is replaced.
- A truncated string, marker included, never exceeds the configured limit.
- When the content under decision is longer than the limit, Defender sees
only a truncated copy: a block stands, but an allow does not cover the
rest, so the result is marked truncated and follows the fail mode.
Otherwise content padded past the limit would be authorized unseen.
- The tests workspace declares @responsibleai/agent-hooks, and the docs
explain that the emitter's interceptor timeout is fixed at creation.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
…agent-hooks
- The copy sent to Defender is built while reading the context, within a
content budget of four times A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS:
the content under decision first (up to half, as target mirrors it),
then the tool call arguments at post_tool_call, the tool declarations
(the called tool first), the newest messages, extensions and any other
fields. Content under decision cut by the budget is truncated and
follows the fail mode, like a string longer than the limit. Nesting
deeper than 32 levels is cut, and a circular reference is rejected.
- Every outbound string and object key is well formed: a lone surrogate
becomes U+FFFD, which strict JSON parsers require, with a fallback for
Node.js 18.
- Optional context fields and response members of another shape are left
out instead of indexed, with tests for each: model, actor, tenant,
tools, messages and the a365 extension, session, transform, warnings and
labels, 400 diagnostics, JWT payloads and token responses. A transform
of a truncated copy blocks like a deny.
- @responsibleai/agent-hooks is a peer dependency (>=0.1.0-alpha.5
<0.2.0, in a named pnpm catalog) with the alpha.5 pin for development,
so an application and the extension share one copy.
- Docs and CHANGELOG.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
- When two keys of one object become equal once made well formed (a lone
surrogate and U+FFFD), only the first is sent. In the content under
decision the copy is then incomplete, so Defender's allow follows the
fail mode, with its own error, like a truncated copy.
- The called tool is always declared first: from tools, or from the
a365 extension when tools leaves it out.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
instanceof Promise misses PromiseLike values and promises created in another realm. A rejected logging callback of either kind can therefore become an unhandled rejection, despite this method's guarantee that listener failures do not affect execution. Normalize thenables with Promise.resolve before attaching the rejection handler.
Any value other than the exact string closed silently selects fail-open. A typo such as clsoed therefore disables the intended protection precisely when Defender is unavailable. Treat only an absent value or open as open, accept closed, and reject every other value.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
- A365_DEFENDER_RTP_FAIL_MODE accepts open or closed (any case, blank
means open); any other value throws, and the client validates it at
construction, so a typo cannot silently turn fail-closed into
fail-open.
- A rejection from anything thenable that the evaluation listener
returns, including a promise from another realm, is caught, so logging
cannot raise an unhandled rejection.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
…e to spec
- At pre_tool_call and post_tool_call, the called tool's declaration is
searched for by name among the first 10000 declarations and copied
first; otherwise it is declared by name, with the a365 extension's
description. When it lies beyond those 10000, or its own description
or schema had to be cut, Defender's allow is not authoritative: the
result is truncated and follows the fail mode. A tool absent from a
list searched to the end is not. The other declarations follow in host
order within the budget.
- session carries only id, a UTC started_at and a non-negative integer
turn, and trace only string trace_id and span_id, so a malformed node
cannot make Defender reject the request.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
…d URLs
- At a tool call, the called tool's declaration is charged right after
the content under decision, before the post_tool_call arguments. Its
name is copied whole without cost; its description and schema use the
budget, and a cut of either leaves Defender's allow unverified. A list
of more than 10000 declarations without the called tool among the
first 10000 does too; one of at most 10000 does not. Each case has its
own error.
- The endpoint and the token authority are parsed once, and requests go
to the parsed URL.
- The interceptor takes the fail-closed message from the result instead
of repeating the client's text.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
Agent-hooks reserves the host_error: warning namespace for host-generated failures. Passing a Defender warning such as host_error:spoofed through unchanged makes the emitter reject an otherwise valid allow and synthesize a fail-closed host error. Map empty or reserved reasons to defender:warning before returning the verdict.
Restrict tenant copying to accepted fields and size bounds
copyJson uses an infinite budget, and when the host tenant ID matches, every tenant property is spread into the request. A host-supplied tenant extension containing a very large array/object therefore bypasses the advertised 4× content bound (and is traversed in full). Rebuild this envelope from accepted spec fields such as id and name, as is already done for agent/session/actor/model/trace.
This issue also appears in the following locations of the same file:
line 455
line 542
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
- Neither the Defender request nor the token exchange follows a
redirect, so the context, token and client assertion are never resent
elsewhere; a redirect fails like any transport error.
- ENABLE_A365_DEFENDER_RTP accepts only true/false, 1/0, yes/no or
on/off, like A365_DEFENDER_RTP_FAIL_MODE accepts only open/closed, so
a typo fails at startup.
- The evaluation listener runs after the verdict is returned, off the
interceptor's timed path.
- tenant carries only id and, when the host's tenant id matches, name.
- A request_id that is not a string falls back like a missing one.
- A Defender warning reason in the host_error namespace, which
agent-hooks reserves for the host, becomes defender:warning, so it
cannot turn an allow into a host error.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
The client deadline starts only after prepare has fitted the context, while the agent-hooks emitter timeout already covers the interceptor. Because defenderRtpMaxContentCharacters has no upper bound, fitting a correspondingly large context can consume the fixed 2-second margin and make the emitter return host_error:interceptor_timeout before the client applies its configured fail mode. Start the Defender deadline before preparation (and dispose it if preparation throws) so it is always scheduled ahead of the emitter timeout.
Track explicit sequences to preserve monotonic session ordering
A valid explicit sequence is forwarded without updating this session's generated-sequence high-water mark. If the host sends sequence 7 and the next context omits it, nextSequence emits 1, breaking the agent-hooks monotonic sequence contract and misordering Defender/audit records. Record explicit values in the per-session tracker (without lowering an existing high-water mark) before generating later missing values.
A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS and
A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS were read with parseInt, so
`10s` became 10 (and `1e4` 1, `10.5` 10). A 10 ms timeout passed the
range check, timed out every evaluation and, failing open by default,
silently turned Defender off. Both now go through wholeNumber: unset or
blank keeps the default, and anything but digits throws
"<name> must be a whole number." The range checks are unchanged.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
When a session mixes host-supplied and generated sequences, the generated value can move backwards. For example, an explicit sequence: 100 followed by an omitted sequence produces 1, because explicit values never update this.sequences. AGENT-HOOKS-0.1 requires sequence to be strictly increasing across all interception points in a session. Record valid explicit values (at least the per-session maximum) before generating later values, including the existing eviction behavior.
Generic copying bypasses sanitization for other point fields
Only the current point's field is reserved here. Fields for other points fall through to the generic copier without their contract-specific sanitization; for example, an input context with a stale tool_call: { provider_meta: ... } sends that member unchanged, which the Defender validator rejects and turns into an unverified fail-mode result. Reserve all point fields from generic copying so only the field rebuilt for the active point is emitted.
Only the active point's field was reserved from the generic copy of
other top-level fields, so an input context carrying a stale tool_call
(or tool_result or output) sent it unchanged; with non-spec members,
Defender rejects the request and the evaluation goes unverified.
input, output, tool_call and tool_result are now reserved at every
point, as in the .NET SDK's KnownMembers: the active point's field is
rebuilt and the others are left out.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
An all-digit A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS such as 308 nines
is finite and an integer, but the client's budgets are multiples of it
and overflowed to Infinity, so the copy was no longer bounded. The limit
is now at most 2147483647, the ceiling the .NET (int32) and Python SDKs
enforce, and a larger value throws "defenderRtpMaxContentCharacters must
be a positive integer of at most 2147483647."
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3eefcbbf-c4df-4105-81a3-40e89ce0364b
A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS and
A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS reject values that are not whole
numbers, and the content limit is at most 2147483647, since #310; note it
in CLAUDE.md and the tooling design document, next to the matching
Purview rows.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ffc2218e-29c5-4011-8ea5-e36163d58d4c
A365_PURVIEW_DLP_MAX_CONTENT_CHARACTERS (and its override) must be at
most 2147483647, as for Defender (#310) and in the .NET and Python SDKs.
A larger value, such as 308 nines, is finite but overflowed the
agent-hooks interceptor's reading budget to Infinity, so structured
content was read without a bound; it now throws, and the interceptor
follows the fail mode.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ffc2218e-29c5-4011-8ea5-e36163d58d4c
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds opt-in real-time protection from Microsoft Defender for AI ("Defender RTP") to the Agent 365 Node.js SDK, on the Responsible AI agent-hooks control contract (AGENT-HOOKS-0.1). The host emits an agent-hooks context at each interception point, an interceptor posts it to Defender's prevention endpoint, and Defender's verdict decides allow or deny. This is the Node.js part of a three-SDK change, with .NET (microsoft/Agent365-dotnet#266) and Python (microsoft/Agent365-python#278); the API shape, defaults and environment variables match.
@microsoft/agents-a365-tooling(no new dependency):DefenderRtpClient:evaluateHookContextsends an agent-hooks/0.1 context toPOST .../v1/protection/evaluateand returns aDefenderRtpEvaluationResult.prefetchAccessTokenwarms the token cache,isEvaluatedInterceptionPointreports the four points Defender evaluates, andunavailablebuilds a not-evaluated result.DefenderRtpTokenResolverandDefenderRtpTokenResolvers.fromAgenticConnection, which return the agent identity's app-only token.DefenderRtpAgentContext, plus six Defender settings onToolingConfiguration, read from the environment or from override functions.@microsoft/agents-a365-tooling-extensions-agenthooks(new package, preview), the only package that uses@responsibleai/agent-hooks. It is a peer dependency (>=0.1.0-alpha.5 <0.2.0), so the application installs it alongside and both share one copy:A365DefenderInterceptor, an agent-hooksInterceptorregistered asdefender, with an evaluation callback for logging;A365DefenderCallcarries the identity and token resolver for each context.createProtectionEmitter(enforce mode,parallel/strictest) andaddA365Defender.docs/design.md,CLAUDE.md), CHANGELOG, the package lists (jest mapper, dependency graph, build and release docs), and a named pnpm catalog (peers) for the peer range.Scope: Defender RTP and the agent-hooks interceptor only. No Purview and no samples.
Supersedes #277, which targeted an older Defender endpoint and API. That PR is left open for its owner to close.
How it works
Interception points. Defender evaluates four points. Any other point (
agent_startup, model calls,agent_shutdown) is allowed locally with no call, as is every point while Defender RTP is disabled.denyinputpre_tool_callpost_tool_calloutputRequest. The body is a copy of the host's
AgentContext, built field by field while reading it (the context is never serialized whole) and fitted to Defender's request validation; the host's context is never modified. The copy keeps the session, sequence and tool-call ids, and is fitted as follows:specisagent-hooks/0.1,timestampis UTC, andsequenceis ≥ 0.agent.idis the Entra agent identity,agent.frameworkis sanitized to^[a-z0-9_-]+$, andsession.idis required.targetequals the point's field,tool_call/tool_resultcarry only spec members, and loosely filled optional fields are repaired or dropped. Only the active point's field is sent: aninput,output,tool_callortool_resultleft over from another point is left out, as in the .NET SDK.modeloractor, or ana365extension that isn't an object) is left out rather than indexed, so it can't fail an evaluation.sessionandtracecarry only their spec members of the right shape:id, a UTCstarted_atand a non-negative integerturn; stringtrace_idandspan_id.tenantcarries onlyid, always the agent's tenant since it must equal the token'stid, and the host'snamewhen the host's tenant id matches.String.prototype.toWellFormed, with a fallback on Node.js 18). OtherwiseJSON.stringifywould write a\uD8xxescape that Defender's JSON parser rejects, and the request would fail, by default open. When two keys of one object become equal that way, only the first is sent; in the content under decision that makes the copy incomplete (see Long content).A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERScharacters, truncation marker included, and nesting deeper than 32 levels is cut. Identifiers and protocol fields are sent unchanged.A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERSof content. Every copied element counts at least one character: strings, keys and other values count their length (so empty strings and nulls count too), each array, object, tool declaration and message counts one more, and tool declarations and messages count their keys. Only the called tool's name is copied whole without cost. A million name-only tools or empty messages are therefore trimmed like any other content. It's filled in this order:targetsends it twice;post_tool_call;pre_tool_callandpost_tool_call, Defender decides with the called tool's declaration, so it comes first and is always present.toolsentries and copied first: its name whole, its description and schema within the budget. Otherwise it is declared by name, with thea365extension's description.sequence, the client numbers each session's contexts itself, for the last 1000 sessions. A session seen again after that resumes above every number given to a dropped session, sosequencekeeps increasing withinsession.id.request_idthat isn't a string falls back to the agent's request id, like a missing one.Correlation. Every call sends a unique
x-ms-correlation-id(GUID), returned asDefenderRtpEvaluationResult.correlationId. Defender logs each evaluation under it.Verdict.
allowproceeds, and Defender'swarningsandresult_labelscarry through to the agent-hooks verdict. A warning reason in thehost_error:namespace, which agent-hooks reserves for the host, becomesdefender:warning, so it can't turn an allow into a host error.denyblocks with Defender's message, and the agent-hooks verdict (defender:block:<reason>) carries the correlation id as evidence.transformalso blocks, because this version doesn't apply rewrites.transformthat isn't an object, warnings or labels that aren't arrays) are ignored, so they never cost Defender its decision.Long content. The content under decision (the message, the tool call arguments, the tool result or the reply) may not fit: a string can be longer than
A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS, or the content can exceed its share of the total. Then Defender sees only a truncated copy. A block (denyortransform) of the copy stands, but an allow doesn't cover the rest: the result is markedtruncatedand follows the fail mode, like a missing verdict. Otherwise content padded past the limit would be authorized unseen. The same applies, with its own error, when two keys of the content under decision become one once made well formed, or when the called tool's declaration is incomplete.Authentication. Defender is always called app-only as the agent identity. That covers user turns, autonomous runs, A2A and startup prefetch, where there is no user token.
The Agents SDK connection returns the agent identity's assertion through
getAgenticApplicationToken(tenantId, agentAppInstanceId). This is the same authority Observability S2S export uses.fromAgenticConnectionexchanges the assertion athttps://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token:grant_type=client_credentials;client_id= the agent identity, with ajwt-bearerclient assertion;api://86a21212-634e-4553-b3d6-e477e4c9d9ec/.default.The token carries the app role
RealtimeProtection.Evaluate.All.Tokens are cached per tenant, agent and scope until 5 minutes before they expire.
The endpoint and the token authority must be
httpsURLs with a host. Each is parsed once, and requests go to the parsed URL. Neither request follows a redirect, so the context, token and client assertion are never resent elsewhere; a redirect fails like any transport error.Fail mode. A timeout, token or transport failure, non-2xx response, or any other send or read error marks the result not evaluated (
evaluated: false, witherrorandhttpStatus).A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS) covers the token acquisition and the request.createProtectionEmittergives the emitter a longer interceptor timeout and rejects one that isn't longer, so the client's fail mode applies before the emitter times out. The agent-hooks default of 5 s is below the client's 10 s, and the README tells hosts that build their own emitter to raise it. The timeout is fixed when the emitter is created, so with per-request Defender timeouts hosts create the emitter per turn or pass a larger timeout. Both timeouts must stay within Node's largest timer delay (2147483647 ms), since a longer timer fires after 1 ms: the Defender timeout is at most 2147481647, and the emitter rejects an interceptor timeout that isn't an integer in range.defender:unverifiedwarning.A365_DEFENDER_RTP_FAIL_MODE=closeddenies withruntime_error:defender_unverified, which is never reported as a detection.400reports the failed rules from Defender'sdiagnostics.validationErrorsinerror; diagnostics of another shape fall back to the title. A token whose payload has no numericexpis used but not cached.Configuration
ENABLE_A365_DEFENDER_RTPtrue(or 1, yes, on) to call Defender; off by default (false, 0, no, off or unset); any other value fails at startupA365_DEFENDER_RTP_ENDPOINThttps://<host>/v1/protection/evaluate(required when enabled)A365_DEFENDER_RTP_FAIL_MODEclosedblocks when no verdict is obtained;open(the default) allows; any other value is rejected, so a typo can't silently fail openA365_DEFENDER_RTP_TIMEOUT_MILLISECONDS10sfails at startup instead of becoming 10 msA365_DEFENDER_RTP_AUTHENTICATION_SCOPEA365_DEFENDER_RTP_MAX_CONTENT_CHARACTERSThe same settings are available as
ToolingConfigurationoverride functions, for per-tenant or per-request configuration.Installation.
npm install @microsoft/agents-a365-tooling-extensions-agenthooks @responsibleai/agent-hooks@0.1.0-alpha.5. The application installs the agent-hooks peer itself, so the emitter,AgentContextBuilderandproceedsit imports come from the same copy the extension uses.Prerequisites.
RealtimeProtection.Evaluate.Allon the Defender API (86a21212-634e-4553-b3d6-e477e4c9d9ec).a365 setup allwill grant it once Add Defender permissions part of "a365 setup all" Agent365-devTools#485 ships.Otherwise Defender answers
403, which follows the fail mode.Testing
npm run lint,npm run buildandnpm testpass locally (thetestsworkspace: 69 suites, 1569 tests, including the 252 new tests below).pnpm install --frozen-lockfilepasses with pnpm 10.20.0, andpnpm packpublishes the peer range and no runtime dependency on agent-hooks.DefenderRtpClienttests (tests/tooling/defender-rtp-client.test.ts). Each request body is checked against the endpoint's request validation rules (ported from the .NET tests). They cover:denyortransformof the copy stands, content at the limit is evaluated normally, and truncation outside the content under decision doesn't count;truncated;truncated;post_tool_callarguments; unverified at both tool points when it lies beyond the first 10,000, or when its description or schema is cut; without it, a list of 10,000 declarations is not truncation and one of 10,001 is; and the other declarations following in host order;sessionandtracereduced to their spec members of the right shape;sequencekeeps increasing after the session is no longer tracked;toWellFormedand the Node 18 fallback: the raw body has no\uD8xxescapes, and a payload behind a lone surrogate is still evaluated and denied. Keys that become one in the content under decision make the copy incomplete (fail closed denies, fail open allows and says why); elsewhere they don't count;model,actor,tenant,toolsormessages, arequest_idthat isn't a string (which falls back to the agent's), and ana365extension that is a string, has a stringtool, or is an array;session, which is rejected;denyandtransformwith atransformof another shape;exp(used, not cached);input,output,tool_call,tool_result) never sent atinput,pre_tool_calloroutput.DefenderRtpTokenResolvers.fromAgenticConnectiontests: token URL and form fields, the assertion, Entra error codes without the body, non-2xx, malformed JSON, success responses of another shape (no or non-stringaccess_token, an array, a string,null), error bodies of another shape, cancellation, an https-only authority, the token endpoint built from the parsed authority, and no redirects.ENABLE_A365_DEFENDER_RTP(true/false, 1/0, yes/no, on/off, and the rejection of anything else),open/closedin any case, the rejection of other fail modes, the upper bounds of the timeout and of the maximum content characters (2147483647: 308 nines, which would overflow the budget to Infinity, are rejected), and whole numbers for the timeout and the maximum content characters (10s,1e4,10.5,-5and the like are rejected rather than read byparseInt; blank keeps the default).A365DefenderInterceptortests, run under the real agent-hooks native emitter. They cover:host_error:namespace, remapped so the allow stands;transformof another shape still blocks as a detection; a context whosesessionisn't an object follows the fail mode without throwing; optional members of another shape are left out;enginesfield (≥ 20).A365DefenderInterceptorrunning fail-closed on Node.js 18 and 24.fromAgenticConnection: an app-only token withRealtimeProtection.Evaluate.All.pre_tool_callwas denied (defender:block:prevention_blocked, with Defender's threat label on the verdict), and the tool did not run.Known limitations and follow-ups
@responsibleai/agent-hooksis a peer dependency, tested with0.1.0-alpha.5.>=0.1.0-alpha.5 <0.2.0, admits0.1.0-beta.1. Development, tests and the lockfile pin0.1.0-alpha.5.engines.node >=20.0.1.0-beta.1. Testing against beta.1 and moving the pin, with its lockfile update, is a follow-up.A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS, or more than its share of the 4× total, and Defender doesn't block the truncated copy.transformis treated as a block. Rewrites aren't applied;transformPathis surfaced on the verdict.a365 setupgrants it only once Add Defender permissions part of "a365 setup all" Agent365-devTools#485 merges; until then a tenant administrator grants it once per agent blueprint, as the README describes.Related work
RealtimeProtection.Evaluate.All) as part ofa365 setup all.