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 - #278
Adds opt-in real-time protection from Microsoft Defender for AI ("Defender RTP") to the Agent 365 Python 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. The API matches the .NET SDK (microsoft/Agent365-dotnet#266) with Pythonic names.
microsoft-agents-a365-tooling: new module microsoft_agents_a365.tooling.protection.defender, with no agent-hooks dependency.
DefenderRtpClient: evaluate_hook_context() sends a fitted copy of an agent-hooks/0.1 context to POST .../v1/protection/evaluate and returns a DefenderRtpEvaluationResult; prefetch_access_token() warms the token cache; is_evaluated_interception_point() and unavailable() as in .NET.
DefenderRtpOptions (from_environment()), DefenderRtpAgentContext, the DefenderRtpTokenResolver callable type, and DefenderRtpTokenResolvers.from_agentic_connection() (agent identity token).
The package now declares aiohttp, which it already imported.
microsoft-agents-a365-tooling-extensions-agenthooks (new package): A365DefenderInterceptor (an agent-hooks interceptor registered as defender, with to_verdict()), A365DefenderCall, create_protection_emitter() (enforce mode, parallel/strictest) and add_a365_defender(). It depends on agent-hooks-sdk, imported as agent_hooks (agent-hooks on PyPI is an unrelated package).
Workspace: the new package is registered in the root pyproject.toml (members, sources, and the centralized agent-hooks-sdk >= 0.1.0a5 constraint), uv.lock (the new member and agent-hooks-sdk 0.1.0a5 only), the published-metadata test, and the repo docs.
Docs: package README, design docs, and CHANGELOGs ([Unreleased] → Added).
Scope: Defender RTP and the agent-hooks interceptor only. No Purview, no samples.
How it works
Interception points. Defender evaluates four points; any other point (agent_startup, model calls, agent_shutdown) is allowed locally with no call.
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. Defender receives a fitted copy of the host's agent-hooks AgentContext, keeping its session, sequence and tool-call ids; the host's context is not modified (nor copied whole).
spec = agent-hooks/0.1, timestamp in UTC, sequence ≥ 0 (numbered per session when missing).
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 (content_hash and duration_ms are kept when valid), and loosely filled optional fields are repaired or dropped.
tenant.id is always the agent's tenant, since it must equal the token's tid (a different host value is replaced); actor, request_id and model.id are filled from DefenderRtpAgentContext when missing (a request_id that is empty or not a string counts as missing).
A valid sequence the host set is sent unchanged; the client doesn't repair the order of host values. A missing or invalid one is generated above the highest sequence seen in the session, host values included. The client tracks up to 1,000 sessions, and a session it no longer tracks continues above the highest sequence of every session it dropped, so generated values never repeat or decrease within a session.
The envelope (agent, session, tenant, actor, request, model, trace, and roles, tool names and ids) is built from its spec fields alone (session keeps id, a UTC started_at and a non-negative turn; tenant its name; trace its ids), so nothing else a host puts there escapes the budget, and it is never cut, so the request validates whatever the limit. Content is fitted to a budget (see Long content).
Every string and key is sent as valid Unicode: split surrogate pairs are rejoined and lone surrogates become U+FFFD, so such text can't keep the request from being sent. NaN and infinities are sent as text.
Every optional node is shape-checked before it is read (context members such as model or the a365 extension, verdict members such as transform, 400 diagnostics, the token payload and the token response); one of an unexpected shape is ignored, so it can't turn a verdict into an unavailable result.
Correlation. Every call sends a unique x-ms-correlation-id (GUID), returned as DefenderRtpEvaluationResult.correlation_id; Defender logs the evaluation under it.
Verdict.
allow proceeds, passing Defender's warnings and result_labels through to the agent-hooks verdict. A warning reason that is empty or in the host_error: namespace, which agent-hooks reserves and would reject as an invalid verdict (a deny), becomes defender:warning.
deny blocks with Defender's message (reason defender:block:<reason>), and the agent-hooks verdict carries a correlation evidence pointer.
transform also blocks, because this version doesn't apply rewrites.
Long content. Each content string is cut to A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS (default 20000, with a ...[truncated N chars] marker when it fits), and all content in one request shares a budget of four times that, so the request and the time to prepare it stay bounded. N counts characters as sent, so a rejoined surrogate pair is one; a non-ASCII string longer than the twice-the-limit prefix that is read gets ...[truncated], since counting the rest would mean reading all of it. The content under decision (the user's message, the tool call's arguments, the tool result, or the reply) is sent twice, as the point's field and as target, so it may use half of the budget. The rest goes, in order, to the called tool's declaration (below), the call's arguments at post_tool_call (already decided on at pre_tool_call, so cutting them is not a truncation), the other tool declarations, the newest messages, extensions and other members. Message histories, extension namespaces and the other declarations are read only as far as the budget reaches, so a long one costs no more than what is sent. Nesting deeper than 32 levels is cut. When the content under decision itself is cut, Defender evaluates a truncated copy, and its verdict can't cover the rest:
a Defender deny (or transform) still blocks;
an allow is not authoritative: the result has truncated=True and follows the fail mode (fail open allows with a defender:unverified warning, fail closed blocks with runtime_error:defender_unverified).
This closes a bypass where padding content past the limit put the rest beyond inspection, even when failing closed. Agents that handle long content should raise the limit.
The called tool's declaration. At pre_tool_call and post_tool_call, Defender's verdict also depends on how the called tool is declared, so its declaration counts like the content under decision:
it is searched for by name among the first 10,000 entries of tools and charged right after the content under decision, ahead of the call's arguments at post_tool_call and of the other declarations, with its name whole; the other declarations then fill what is left of the budget in the host's order;
an allow follows the fail mode (truncated=True, with its own error) when its description or schema had to be cut, or when tools is longer than 10,000 entries and the called tool isn't among the first 10,000;
a list searched in full that doesn't declare the called tool is not a truncation. When none of the entries searched declares a tool, the called tool is declared from the a365 extension's tool.description, whose cut counts the same.
Authentication. Defender is always called app-only as the agent identity. That covers user turns, autonomous runs, A2A and startup prefetch, where there's no user token.
The Agents SDK connection returns the agent identity's assertion via AccessTokenProviderBase.get_agentic_application_token (implemented by MsalAuth). This is the same authority Observability S2S export uses.
from_agentic_connection exchanges it at https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token (client_credentials, client_id = agent identity, jwt-bearer client assertion) for api://86a21212-634e-4553-b3d6-e477e4c9d9ec/.default. The token carries the app role RealtimeProtection.Evaluate.All. The token-endpoint response body is never surfaced in errors.
Tokens are cached per tenant, agent and scope until they expire, with a single in-flight acquisition per key that is dropped when it completes; a failed acquisition is never cached. Within five minutes of expiry, a call refreshes the token in the background and keeps using the cached one, also when the refresh fails. A token resolver may be sync or async; a synchronous one runs on a worker thread, so it can't block the event loop or outlast the deadline, and since a timeout can't stop that thread, one call per agent, tenant and scope runs at a time, so a resolver that blocks can't pile up threads.
The endpoint and the token authority must be absolute HTTPS URLs, and redirects are not followed (a 3xx follows the fail mode, or is a token failure). The client keeps a private copy of its options, so a later change to the caller's object can't bypass that check.
Deadline and fail mode. Token acquisition and the call share one deadline (A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS). On a timeout, transport error (including any exception from the HTTP session other than the caller's cancellation), token failure, an exception from the call resolver or the token resolver, a call resolver that returns no call (no agent identity), a non-2xx response or a response without a verdict, the result is marked not evaluated (evaluated=False, with error and http_status) and reaches on_evaluated. The call resolver runs only for the four points Defender evaluates while Defender RTP is enabled. When resolving the identity or evaluating raises, only the exception's type reaches the result (evaluation failed (<type>)), and so the verdict and the interception record, since its message can carry credentials or content; the exception is logged. on_evaluated runs on a worker thread once the verdict is decided, outside the emitter's interceptor timeout, so a slow or failing callback can't change the verdict.
Fail open (the default) allows the action with an agent-hooks defender:unverified warning.
A365_DEFENDER_RTP_FAIL_MODE=closed denies with runtime_error:defender_unverified, which is never reported as a detection. Only open and closed are accepted, so a typo can't silently fail open.
A 400 surfaces the failed rules from Defender's diagnostics.validationErrors in error.
create_protection_emitter() sets the agent-hooks interceptor timeout to the Defender timeout plus two seconds, so the client's fail mode applies before an emitter timeout (which would deny whatever the fail mode). agent-hooks' own default interceptor timeout is 5 seconds, below the client's 10; the README tells hosts that build their own emitter to set it above the Defender timeout.
Configuration
Variable
Meaning
ENABLE_A365_DEFENDER_RTP
true (also 1, yes, on) to call Defender; false (0, no, off) or unset not to; any other value is rejected
A365_DEFENDER_RTP_ENDPOINT
the prevention endpoint, https://<host>/v1/protection/evaluate (required when enabled; HTTPS only)
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
one deadline for token acquisition and the call (default 10000)
A365_DEFENDER_RTP_AUTHENTICATION_SCOPE
overrides the Defender API scope
A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS
cuts each content string (default 20000); all content in a request shares four times that
Prerequisites.
The agent identity's app-only token carries the application permission RealtimeProtection.Evaluate.All on the Defender API (86a21212-634e-4553-b3d6-e477e4c9d9ec). Add Defender permissions part of "a365 setup all" Agent365-devTools#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 (steps in the README).
The agent's tenant is onboarded to Microsoft Defender for AI.
Otherwise Defender answers 403, which follows the fail mode.
ruff check and ruff format --check pass; scripts/verify_constraints.py passes; uv lock --check passes.
pytest tests/ -m "not integration": 1294 passed, 3 skipped, on Python 3.11 and 3.12 in CI (Linux) and on Python 3.12 locally (Windows). This includes the published-metadata test, which builds every package's wheel, now including the new one.
447 new test cases, each request body checked against the endpoint's request validation rules:
303 DefenderRtpClient cases (including a 99-case grid for the truncation marker):
Forwarding: the disabled client and points Defender doesn't evaluate; the correlation id and agent identity; tool-call and tool-result fitting (content_hash and duration_ms kept when valid); repair of loose optional fields; fill-in from the agent context (a request_id that is empty or not a string falls back to the turn's); sequences per session, including a session dropped from the bounded cache.
Fitting: the envelope kept whole at a limit of 1, and built from its spec fields alone (megabytes of padding in session, tenant, trace and actor are not sent); members of an unexpected shape ignored.
Long content: every content string clamped; the content under decision limited to half the budget; the rest filled in order, with the called tool's declaration charged before the arguments at post_tool_call; histories read only as far as the budget reaches; a 100,000-item tool result bounded; deep nesting cut; oversized member names normalized at most once; an allow of a truncated copy following the fail mode, while a deny or transform of it still blocks.
The called tool: found in a padded list and declared first, whole; beyond the first 10,000 entries (or a longer list without it) following the fail mode, with nothing beyond those entries read for the search; absent from a list searched in full, with no truncation; its description or schema cut, or crowded out of the budget, following the fail mode; a deny still blocking; another tool's cut not counting; a cut description from the a365 extension counting.
Encoding: lone and split surrogates in values and keys (a split pair counts as the one character it becomes), the marker's count in characters as sent (no count when the omitted rest holds surrogates), and NaN or infinities, still sent and evaluated.
Verdicts: deny, allow with warnings, and transform-as-block, including verdict members of an unexpected shape.
Failures: fail open and closed on HTTP errors, token failures and unexpected session errors; 400 validation rules (including diagnostics of an unexpected shape); a 2xx with no decision, non-JSON bodies and unreadable bodies; the shared deadline across token acquisition and the call; caller cancellation.
Tokens: caching, prefetch, concurrent acquisition, cancelled and failed acquisitions, background refresh (including a failed refresh that keeps the cached token), tokens whose payload has an unexpected shape (used, not cached), and a blocking synchronous resolver that neither stalls the event loop nor outlasts the deadline, and is called once at a time.
70 DefenderRtpOptions cases: environment parsing (an enable flag of true/1/yes/on or false/0/no/off and a fail mode of open or closed only; positive integers up to 2147483647), defaults (10 s, 20000 characters, fail open), HTTPS-only endpoints, and validation (including NaN, infinite and out-of-range timeouts and non-integer limits).
26 from_agentic_connection cases: the token URL and form fields, the assertion (and a missing or non-string one), both get_agentic_application_token signatures (hosting-core 0.4 to 0.7, and 0.8+), non-2xx without the response body, token responses without a string access_token, cancellation, HTTPS-only authorities, and session handling.
48 A365DefenderInterceptor cases, run under the real native agent-hooks emitter:
Verdicts: forward and allow; a denied tool call; emit() raising InterceptionBlocked; warnings and labels on an allow, including a reserved host_error: warning reason that no longer turns the allow into a deny; verdict mapping with evidence and labels.
Fail mode: Defender unavailable or timing out (no emitter timeout); an invalid identity; no agent identity resolved; a failing call resolver or token resolver, with only the exception's type in the record and the exception logged.
Scope: points Defender doesn't evaluate, and a disabled client (no call resolved).
Long content: 20,000 harmless characters plus a payload Defender would deny (fail closed blocks, fail open allows with a warning); a deny or transform of truncated content still blocks; a called tool beyond the first 10,000 declarations following the fail mode.
Other: a lone surrogate in an emitted tool argument never failing open; an async call resolver; a failing evaluation callback, and a slow one that neither holds up nor changes the verdict; the emitter settings, including a rejected interceptor timeout that is not positive and finite.
End to end: verified live against the Defender prevention endpoint (re-run after the request-fitting changes), with the agent-hooks emitter and A365DefenderInterceptor running fail-closed and authenticating as an agent identity through from_agentic_connection: clean traffic was allowed at all four points; a known-malicious URL passed as a tool argument was denied at pre_tool_call (defender:block:prevention_blocked) and the tool did not run.
Known limitations and follow-ups
Long content is not fully evaluated. Content beyond the budget isn't sent; an allow of a truncated copy follows the fail mode (it never authorizes the unseen rest). Evaluating long content in chunks would give full coverage under fail open at the cost of extra calls; that is a follow-up.
Response sizes are not capped. Bodies from the Defender endpoint and the token authority are read whole, within the deadline. Both are trusted HTTPS services with redirects refused, as in the .NET and Node.js SDKs; a size cap would be cross-SDK hardening.
transform is treated as a block. This version doesn't apply Defender rewrites (transform_path is surfaced on the verdict).
Prerelease dependency.agent-hooks-sdk is prerelease. The constraint is >= 0.1.0a5 and the lock resolves 0.1.0a5; bumping to 0.1.0b1 is a follow-up (the emitter, context and interceptor APIs this package uses are unchanged in 0.1.0b1). Its native core ships wheels for Windows x64, manylinux x86_64/aarch64 and macOS only, so the extension doesn't install on musl (Alpine); the core client in microsoft-agents-a365-tooling does.
Not yet wired into the hosting pipeline. Hosts register the interceptor on their own agent-hooks emitter; wiring it into the Agents SDK hosting pipeline is a follow-up.
hosting-core before 0.8. In microsoft-agents-hosting-core 0.4 to 0.7 (the declared floor is 0.4.0), get_agentic_application_token takes no tenant, so the assertion comes from the connection's configured tenant; 0.8 and later pass the agent's tenant.
…nsion
Add opt-in Microsoft Defender for AI real-time protection (Defender RTP) to
microsoft-agents-a365-tooling, and a new package,
microsoft-agents-a365-tooling-extensions-agenthooks, that plugs it into the
agent-hooks control contract (AGENT-HOOKS-0.1). The API mirrors the .NET SDK.
- DefenderRtpClient sends a fitted copy of each agent-hooks context emitted at
input, pre_tool_call, post_tool_call and output to Defender's prevention
endpoint as the agent identity, with a unique x-ms-correlation-id, and
returns the verdict. Other points are allowed without a call. The copy meets
Defender's request validation and every string value is clamped.
- Token acquisition and the call share one deadline. A timeout, transport or
token failure, non-2xx response or missing verdict is not evaluated and
follows the fail mode (open by default). The endpoint must be HTTPS.
- DefenderRtpTokenResolvers.from_agentic_connection exchanges the agent
identity's assertion from the agent's connection for its app-only Defender
token. The client caches it, shares one acquisition between concurrent
calls, and refreshes it in the background before it expires.
- DefenderRtpOptions.from_environment reads the A365_DEFENDER_RTP_* settings:
10 s timeout, 20000 max content characters, fail open.
- A365DefenderInterceptor, A365DefenderCall, create_protection_emitter and
add_a365_defender register the client on an agent-hooks emitter.
- The tooling package has no agent-hooks dependency; agent-hooks-sdk is a
dependency of the new extension only. Tooling now declares aiohttp, which
it already imports.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
- Keep a private copy of DefenderRtpOptions and return copies from
DefenderRtpClient.options, and check for an HTTPS endpoint again before
sending, so a later change to the caller's options cannot send the token
over plaintext.
- Always send the agent's tenant as tenant.id. The token is issued in that
tenant and Defender requires them to match, so a different host value
could only be rejected, and a rejection follows the fail mode.
- Number a missing sequence after the highest sequence seen in the session,
including host-set values, so sequences keep increasing.
- Keep a truncated string within max_content_characters, marker included;
when the marker doesn't fit, cut the string without one.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
- Call the A365DefenderCall resolver only for the points Defender evaluates
while Defender RTP is enabled.
- An exception from the call resolver now follows the fail mode, like one
from the token resolver, instead of failing the emission as a host error.
- README: document the one-time, blueprint-level grant of
RealtimeProtection.Evaluate.All that every agent identity inherits, until
a365 setup grants it.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
- Send the Defender evaluation and the token exchange with redirects
disabled. A 307/308 would resend the context, the bearer token or the
agent identity assertion to a target that never passed the HTTPS check.
A 3xx from Defender follows the fail mode; from the token endpoint it is a
token failure.
- CHANGELOG: keep one Added section under Unreleased.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
This generic clamp also rewrites contract metadata. For example, with max_content_characters=3, input.role="user" becomes "use"; a long sanitized agent.framework gets a marker containing characters outside ^[a-z0-9_-]+$. Defender then rejects the request and the default fail-open mode allows the action unevaluated. Make clamping path-aware for fixed enums/schema-constrained fields (or otherwise preserve their validity), and cover a low-cap request in the contract tests.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
Rebuilding these objects drops valid AGENT-HOOKS-0.1 members: tool_call.content_hash is lost here, and tool_result.duration_ms is likewise omitted below. The alpha.5 contract defines both as optional spec fields, and the native builder can emit duration_ms, so valid host context is silently changed rather than merely filtering provider-specific members. Preserve these fields when they satisfy their contract validation.
Content clamping truncates tenant and agent identity IDs
The recursive clamp also truncates tenant.id (and agent.id). For example, the accepted value max_content_characters=1 changes a normal Entra tenant GUID to one character, so it no longer matches the token's tid; Defender then rejects every request and the default fail-open mode allows actions without evaluation. Exempt identity/contract metadata from content clamping (or enforce a safe lower bound) and cover a low-limit request where the tenant ID remains intact.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
Content beyond max_content_characters was cut from the Defender request
but stayed in the host's context, so Defender's allow of the shortened
copy authorized the original, even when failing closed: padding the
content past the limit put the rest beyond inspection.
- The client tracks whether the content under decision (target: the
input, a tool call's arguments, a tool result, or the output) was
truncated, and reports it as DefenderRtpEvaluationResult.truncated.
- A Defender deny (or transform) of a truncated copy still blocks. An
allow is not authoritative: the result stays evaluated, allowed follows
the fail mode, and error says Defender evaluated a truncated copy.
- DefenderRtpEvaluationResult.verified is false for such an allow, and
the interceptor maps it like a missing verdict: allow with a
defender:unverified warning when failing open, deny with
runtime_error:defender_unverified when failing closed.
- README and design docs: over-limit content follows the fail mode,
raise A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS for long-content agents,
and chunked evaluation is a follow-up.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
validate() accepts NaN and positive infinity because both make <= 0 false. Infinity removes the documented per-call deadline, while NaN produces an invalid event-loop deadline. Reject non-finite timeout values.
This issue also appears on line 141 of the same file.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
…ow without an identity
The fitted copy is now built from the context within a content budget
instead of deep-copying the context and clamping every string, in the
same order as the .NET SDK.
- The envelope (agent, session, tenant, actor, request, model, trace,
roles, tool names and ids) is never cut, so a low
max_content_characters can no longer invalidate the request.
- Each content string is cut to max_content_characters and all content
shares a budget of four times that. The content under decision is
sent twice (as the point's field and as target), so it may use half;
the call's arguments at post_tool_call, tool declarations (the called
tool's first), the newest messages, extensions and other members share
the rest. Nesting deeper than 32 levels is cut. When the content under
decision is cut, truncated is set and an allow follows the fail mode;
a deny or transform still blocks. A 100,000-item tool result now takes
milliseconds to prepare instead of about a second, with a bounded body.
- input, output, tool_call and tool_result are only sent at their own
points, and a member of an unexpected shape (for example a string
model or a365 extension) is ignored.
- Every string and key is sent as valid Unicode (split surrogate pairs
rejoined, lone surrogates replaced), and NaN and infinities as text,
so such content can no longer keep the request from being sent and
leave the decision to the fail mode.
- tool_call.content_hash and tool_result.duration_ms are kept when they
meet the spec.
- The token resolver rejects an assertion that is not a string.
- The interceptor treats a call resolver that returns None (no agent
identity) like an unavailable Defender: it follows the fail mode and
reaches on_evaluated, instead of allowing the context unevaluated.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
DefenderRtpOptions.validate() accepted NaN and infinity as the timeout,
since neither fails "<= 0": infinity removed the per-call deadline and
NaN produced an invalid event-loop deadline.
- validate() requires a positive, finite timeout of at most 2147483.647
seconds and an integer max_content_characters of at most 2147483647.
- from_environment() reads both variables as positive integers up to
2147483647, the range the other Agent 365 SDKs read.
- create_protection_emitter() rejects an interceptor timeout that is not
positive and finite; agent-hooks accepts any float.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
The bounded sequence cache can reset a live session after 1,000 other sessions are observed. When that session next omits sequence, _next_sequence() starts it again at 1, producing a duplicate/decreasing value even though AGENT-HOOKS-0.1 requires sequence values to be unique and strictly increasing within a session. Preserve high-water state for the full session lifetime, or require caller-provided sequence values once state cannot be retained.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
…e pairs
Content was cut to the limit before split surrogate pairs were
rejoined, so a pair split across the limit was sent as a replacement
character and the content marked truncated although it fit: a pair is
two code points before normalization and one after.
Each string is now normalized before it is measured and cut. A
normalized character takes at most two of the original, so only a
prefix of twice the limit is normalized, keeping the work bounded; the
truncation marker still counts the characters omitted from the whole
string.
The hosting-core notes now say that releases before 0.8 (0.4 to 0.7)
take no tenant in get_agentic_application_token.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
The timeout cannot bound a synchronous token resolver because the resolver is invoked directly on the event-loop thread; while it blocks, the timeout callback cannot run. Since DefenderRtpTokenResolver explicitly permits synchronous implementations, both evaluation and prefetch can exceed the configured deadline and stall unrelated async work. Run synchronous resolvers off the event loop, or make the public resolver contract async-only.
Unbounded tool-list scan and copy delays Defender evaluation
At tool interception points this searches the entire host-supplied tool list and then copies the whole list before the fitter applies its budget. A sufficiently large tool registry therefore makes request preparation unbounded in CPU and memory, blocks the event loop, and can consume the Defender deadline before any evaluation occurs. Avoid the full scan/copy—for example, synthesize the called-tool declaration and consume only as many declarations as the remaining budget permits.
Full message pre-pass causes unbounded synchronous preparation
This all(...) pre-pass traverses every message before the content budget is considered. Long conversation histories therefore have unbounded preparation time even though only the newest budget-fitting messages are sent; because this runs synchronously, it can also block the event loop past the Defender deadline. Validate messages incrementally while traversing newest-first and stop once the budget is exhausted.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
…nder load
From Copilot's review of the fitted request and the token path:
- Tool registries, message histories and extension namespaces are read
only as far as the content budget reaches, instead of being scanned
(and the tool list copied) in full first. Messages are validated
newest first as they are read; a malformed one among them still drops
the history. When the called tool is declared beyond the entries
read, it is declared from its name, so a long registry never hides it.
- The sequence cache stays bounded to 1,000 sessions, but a session it
no longer tracks now resumes above the highest sequence of every
dropped session instead of restarting at 1, so a generated sequence
never repeats or decreases within a session.
- A synchronous token resolver runs on a worker thread, so it cannot
block the event loop and the evaluation deadline (and prefetch's
timeout) still applies while it waits.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
The omitted-count marker is incorrect when the truncated text contains UTF-16 surrogate pairs. This prefix is normalized before measuring, but the unseen tail is counted with its pre-normalization length; for 1,000 split-pair emoji at a limit of 40, the request reports ...[truncated 1945 chars] although only 985 normalized characters were omitted. That contradicts the API's rule that a split pair counts as the one character it becomes. Either determine the normalized omitted count or avoid emitting a numeric marker when it cannot be computed within the bounded-work requirement.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
The called tool's declaration informs Defender's verdict at
pre_tool_call and post_tool_call, so an allow is authoritative only if
Defender saw it whole. The previous fallback declared a called tool
beyond the entries read from its name alone, dropping its description
and schema while the allow stayed authoritative.
- The called tool's declaration is searched for by name among the first
10,000 entries of tools and copied first, before any other, with its
name whole. The other declarations fill what is left of the budget in
the host's order.
- The result is truncated (an allow follows the fail mode) when the
called tool's description or schema had to be cut, or when tools is
longer than the entries searched and the called tool is not among
them, each with its own error. A list searched in full that does not
declare the called tool is not a truncation. A cut description of a
called tool declared from the a365 extension counts the same.
- The truncation marker's count no longer mixes units: it counts the
characters as sent (a rejoined pair is one). When the omitted rest
holds surrogates, counting them would mean normalizing all of it,
which costs seconds for a long string, so the marker has no count.
- A member name that does not fit after normalizing spends the rest of
the budget, so repeated oversized names cannot add up to unbounded
work.
- The design doc no longer overstates the sequence guarantee: valid
host values are sent unchanged; only generated values stay above the
highest sequence seen.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
…w gaps
- The called tool's declaration is charged right after the content under
decision, ahead of the call's arguments at post_tool_call, which
Defender already decided on at pre_tool_call. The order is now: the
content under decision, the called tool's declaration, the
post_tool_call arguments, the other declarations, the newest messages,
extensions, then other members. Large arguments can no longer crowd
out the called tool's description and make the verdict unverified.
When none of the entries searched declares a tool, the called tool is
declared from the a365 extension at the same step.
- The envelope is built from its AGENT-HOOKS-0.1 spec fields alone
(session id, started_at and turn; tenant name; trace ids), so members
a host adds to an envelope object no longer escape the content budget.
- A365_DEFENDER_RTP_FAIL_MODE must be open or closed (unset means open);
any other value is rejected, so a typo cannot silently fail open.
- The interceptor records only an exception's type in the result
("evaluation failed (<type>)"), so its message cannot reach the
verdict or the interception record; the exception is logged.
- on_evaluated runs on a worker thread once the verdict is decided,
outside the emitter's interceptor timeout, so a slow callback cannot
turn the verdict into a host error.
- A Defender warning reason in the host_error: namespace, which
agent-hooks reserves and would reject as an invalid verdict, becomes
defender:warning, so it cannot turn Defender's allow into a deny.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
…er threads
- ENABLE_A365_DEFENDER_RTP accepts true/1/yes/on or false/0/no/off
(unset means off); any other value is rejected at startup, so a typo
cannot quietly turn protection off. With the fail mode accepting only
open or closed, this matches the .NET SDK.
- A request_id that is empty or not a string falls back to the turn's
request id, like a missing one, instead of being dropped.
- A synchronous token resolver's worker thread cannot be stopped by a
timeout, so one call per agent, tenant and scope runs at a time: a
later acquisition waits for the same call instead of starting another
thread, and a resolver that blocks can no longer exhaust the shared
executor that evaluation callbacks and host work also use.
- The truncation marker counts the omitted characters only when that is
known from what is read (an ASCII string, or one read in full); a
longer non-ASCII string gets "...[truncated]" instead of a scan of its
whole tail, so preparation stays bounded by the budget.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a08d9836-9dbf-4d1e-bb1f-90ebdf1c01da
The non-finite-number claim does not hold for this extension's documented emitter path. agent-hooks-sdk 0.1.0a5 serializes the context with allow_nan=False and maps NaN/Infinity to a fail-closed host_error:context_invalid before invoking any interceptor, so this interceptor cannot convert or send them and the configured Defender fail mode is not applied. Please document that limitation instead of promising conversion.
…sion id
- Keep at most twice max_content_characters plus one character of
structured content, cutting a long value where the limit needs it, so
reading untrusted content stays bounded; a cut value means not all of
the content was read, so blank text before it follows the fail mode.
- Require evaluate()'s session_id: without the conversation, Purview
cannot group the session's messages.
- create_protection_emitter rejects an explicit interceptor timeout that
does not exceed the Defender timeout or, when Purview is enabled, the
Purview timeout, as in the Node SDK; this tightens the check from #278.
- Note in the README example that get_agentic_tenant_id() needs
hosting-core 0.8+, with the fallback for older releases.
Co-authored-by: Dominik Bezic <dominikbezic@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a4fe1d03-836e-4f40-a934-641b735a38fc
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 Python 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. The API matches the .NET SDK (microsoft/Agent365-dotnet#266) with Pythonic names.
microsoft-agents-a365-tooling: new modulemicrosoft_agents_a365.tooling.protection.defender, with no agent-hooks dependency.DefenderRtpClient:evaluate_hook_context()sends a fitted copy of an agent-hooks/0.1 context toPOST .../v1/protection/evaluateand returns aDefenderRtpEvaluationResult;prefetch_access_token()warms the token cache;is_evaluated_interception_point()andunavailable()as in .NET.DefenderRtpOptions(from_environment()),DefenderRtpAgentContext, theDefenderRtpTokenResolvercallable type, andDefenderRtpTokenResolvers.from_agentic_connection()(agent identity token).aiohttp, which it already imported.microsoft-agents-a365-tooling-extensions-agenthooks(new package):A365DefenderInterceptor(an agent-hooks interceptor registered asdefender, withto_verdict()),A365DefenderCall,create_protection_emitter()(enforce mode,parallel/strictest) andadd_a365_defender(). It depends onagent-hooks-sdk, imported asagent_hooks(agent-hookson PyPI is an unrelated package).pyproject.toml(members, sources, and the centralizedagent-hooks-sdk >= 0.1.0a5constraint),uv.lock(the new member andagent-hooks-sdk0.1.0a5 only), the published-metadata test, and the repo docs.[Unreleased]→ Added).Scope: Defender RTP and the agent-hooks interceptor only. No Purview, no samples.
How it works
Interception points. Defender evaluates four points; any other point (
agent_startup, model calls,agent_shutdown) is allowed locally with no call.denyinputpre_tool_callpost_tool_calloutputRequest. Defender receives a fitted copy of the host's agent-hooks
AgentContext, keeping its session, sequence and tool-call ids; the host's context is not modified (nor copied whole).spec=agent-hooks/0.1,timestampin UTC,sequence≥ 0 (numbered per session when missing).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 (content_hashandduration_msare kept when valid), and loosely filled optional fields are repaired or dropped.tenant.idis always the agent's tenant, since it must equal the token'stid(a different host value is replaced);actor,request_idandmodel.idare filled fromDefenderRtpAgentContextwhen missing (arequest_idthat is empty or not a string counts as missing).sequencethe host set is sent unchanged; the client doesn't repair the order of host values. A missing or invalid one is generated above the highest sequence seen in the session, host values included. The client tracks up to 1,000 sessions, and a session it no longer tracks continues above the highest sequence of every session it dropped, so generated values never repeat or decrease within a session.sessionkeepsid, a UTCstarted_atand a non-negativeturn;tenantitsname;traceits ids), so nothing else a host puts there escapes the budget, and it is never cut, so the request validates whatever the limit. Content is fitted to a budget (see Long content).modelor thea365extension, verdict members such astransform,400diagnostics, the token payload and the token response); one of an unexpected shape is ignored, so it can't turn a verdict into an unavailable result.Correlation. Every call sends a unique
x-ms-correlation-id(GUID), returned asDefenderRtpEvaluationResult.correlation_id; Defender logs the evaluation under it.Verdict.
allowproceeds, passing Defender'swarningsandresult_labelsthrough to the agent-hooks verdict. A warning reason that is empty or in thehost_error:namespace, which agent-hooks reserves and would reject as an invalid verdict (a deny), becomesdefender:warning.denyblocks with Defender's message (reasondefender:block:<reason>), and the agent-hooks verdict carries a correlation evidence pointer.transformalso blocks, because this version doesn't apply rewrites.Long content. Each content string is cut to
A365_DEFENDER_RTP_MAX_CONTENT_CHARACTERS(default 20000, with a...[truncated N chars]marker when it fits), and all content in one request shares a budget of four times that, so the request and the time to prepare it stay bounded. N counts characters as sent, so a rejoined surrogate pair is one; a non-ASCII string longer than the twice-the-limit prefix that is read gets...[truncated], since counting the rest would mean reading all of it. The content under decision (the user's message, the tool call's arguments, the tool result, or the reply) is sent twice, as the point's field and astarget, so it may use half of the budget. The rest goes, in order, to the called tool's declaration (below), the call's arguments atpost_tool_call(already decided on atpre_tool_call, so cutting them is not a truncation), the other tool declarations, the newest messages, extensions and other members. Message histories, extension namespaces and the other declarations are read only as far as the budget reaches, so a long one costs no more than what is sent. Nesting deeper than 32 levels is cut. When the content under decision itself is cut, Defender evaluates a truncated copy, and its verdict can't cover the rest:deny(ortransform) still blocks;allowis not authoritative: the result hastruncated=Trueand follows the fail mode (fail open allows with adefender:unverifiedwarning, fail closed blocks withruntime_error:defender_unverified).This closes a bypass where padding content past the limit put the rest beyond inspection, even when failing closed. Agents that handle long content should raise the limit.
The called tool's declaration. At
pre_tool_callandpost_tool_call, Defender's verdict also depends on how the called tool is declared, so its declaration counts like the content under decision:toolsand charged right after the content under decision, ahead of the call's arguments atpost_tool_calland of the other declarations, with its name whole; the other declarations then fill what is left of the budget in the host's order;truncated=True, with its ownerror) when its description or schema had to be cut, or whentoolsis longer than 10,000 entries and the called tool isn't among the first 10,000;a365extension'stool.description, whose cut counts the same.Authentication. Defender is always called app-only as the agent identity. That covers user turns, autonomous runs, A2A and startup prefetch, where there's no user token.
AccessTokenProviderBase.get_agentic_application_token(implemented byMsalAuth). This is the same authority Observability S2S export uses.from_agentic_connectionexchanges it athttps://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token(client_credentials,client_id= agent identity,jwt-bearerclient assertion) forapi://86a21212-634e-4553-b3d6-e477e4c9d9ec/.default. The token carries the app roleRealtimeProtection.Evaluate.All. The token-endpoint response body is never surfaced in errors.The endpoint and the token authority must be absolute HTTPS URLs, and redirects are not followed (a 3xx follows the fail mode, or is a token failure). The client keeps a private copy of its options, so a later change to the caller's object can't bypass that check.
Deadline and fail mode. Token acquisition and the call share one deadline (
A365_DEFENDER_RTP_TIMEOUT_MILLISECONDS). On a timeout, transport error (including any exception from the HTTP session other than the caller's cancellation), token failure, an exception from the call resolver or the token resolver, a call resolver that returns no call (no agent identity), a non-2xx response or a response without a verdict, the result is marked not evaluated (evaluated=False, witherrorandhttp_status) and reacheson_evaluated. The call resolver runs only for the four points Defender evaluates while Defender RTP is enabled. When resolving the identity or evaluating raises, only the exception's type reaches the result (evaluation failed (<type>)), and so the verdict and the interception record, since its message can carry credentials or content; the exception is logged.on_evaluatedruns on a worker thread once the verdict is decided, outside the emitter's interceptor timeout, so a slow or failing callback can't change the verdict.defender:unverifiedwarning.A365_DEFENDER_RTP_FAIL_MODE=closeddenies withruntime_error:defender_unverified, which is never reported as a detection. Onlyopenandclosedare accepted, so a typo can't silently fail open.400surfaces the failed rules from Defender'sdiagnostics.validationErrorsinerror.create_protection_emitter()sets the agent-hooks interceptor timeout to the Defender timeout plus two seconds, so the client's fail mode applies before an emitter timeout (which would deny whatever the fail mode). agent-hooks' own default interceptor timeout is 5 seconds, below the client's 10; the README tells hosts that build their own emitter to set it above the Defender timeout.Configuration
ENABLE_A365_DEFENDER_RTPtrue(also1,yes,on) to call Defender;false(0,no,off) or unset not to; any other value is rejectedA365_DEFENDER_RTP_ENDPOINThttps://<host>/v1/protection/evaluate(required when enabled; HTTPS only)A365_DEFENDER_RTP_FAIL_MODEopen(default) orclosed, which blocks when no verdict is obtained; any other value is rejectedA365_DEFENDER_RTP_TIMEOUT_MILLISECONDSA365_DEFENDER_RTP_AUTHENTICATION_SCOPEA365_DEFENDER_RTP_MAX_CONTENT_CHARACTERSPrerequisites.
RealtimeProtection.Evaluate.Allon the Defender API (86a21212-634e-4553-b3d6-e477e4c9d9ec). Add Defender permissions part of "a365 setup all" Agent365-devTools#485 adds this toa365 setup; until it ships, a tenant administrator grants it once per agent blueprint and every agent identity created from the blueprint inherits it (steps in the README).Otherwise Defender answers
403, which follows the fail mode.Testing
ruff checkandruff format --checkpass;scripts/verify_constraints.pypasses;uv lock --checkpasses.pytest tests/ -m "not integration": 1294 passed, 3 skipped, on Python 3.11 and 3.12 in CI (Linux) and on Python 3.12 locally (Windows). This includes the published-metadata test, which builds every package's wheel, now including the new one.DefenderRtpClientcases (including a 99-case grid for the truncation marker):content_hashandduration_mskept when valid); repair of loose optional fields; fill-in from the agent context (arequest_idthat is empty or not a string falls back to the turn's); sequences per session, including a session dropped from the bounded cache.session,tenant,traceandactorare not sent); members of an unexpected shape ignored.post_tool_call; histories read only as far as the budget reaches; a 100,000-item tool result bounded; deep nesting cut; oversized member names normalized at most once; an allow of a truncated copy following the fail mode, while a deny or transform of it still blocks.a365extension counting.DefenderRtpOptionscases: environment parsing (an enable flag of true/1/yes/on or false/0/no/off and a fail mode ofopenorclosedonly; positive integers up to 2147483647), defaults (10 s, 20000 characters, fail open), HTTPS-only endpoints, and validation (including NaN, infinite and out-of-range timeouts and non-integer limits).from_agentic_connectioncases: the token URL and form fields, the assertion (and a missing or non-string one), bothget_agentic_application_tokensignatures (hosting-core 0.4 to 0.7, and 0.8+), non-2xx without the response body, token responses without a stringaccess_token, cancellation, HTTPS-only authorities, and session handling.A365DefenderInterceptorcases, run under the real native agent-hooks emitter:emit()raisingInterceptionBlocked; warnings and labels on an allow, including a reservedhost_error:warning reason that no longer turns the allow into a deny; verdict mapping with evidence and labels.A365DefenderInterceptorrunning fail-closed and authenticating as an agent identity throughfrom_agentic_connection: clean traffic was allowed at all four points; a known-malicious URL passed as a tool argument was denied atpre_tool_call(defender:block:prevention_blocked) and the tool did not run.Known limitations and follow-ups
transformis treated as a block. This version doesn't apply Defender rewrites (transform_pathis surfaced on the verdict).agent-hooks-sdkis prerelease. The constraint is>= 0.1.0a5and the lock resolves 0.1.0a5; bumping to 0.1.0b1 is a follow-up (the emitter, context and interceptor APIs this package uses are unchanged in 0.1.0b1). Its native core ships wheels for Windows x64, manylinux x86_64/aarch64 and macOS only, so the extension doesn't install on musl (Alpine); the core client inmicrosoft-agents-a365-toolingdoes.a365 setupgrants the Defender permission only once Add Defender permissions part of "a365 setup all" Agent365-devTools#485 ships; until then it's granted manually (see the README).microsoft-agents-hosting-core0.4 to 0.7 (the declared floor is 0.4.0),get_agentic_application_tokentakes no tenant, so the assertion comes from the connection's configured tenant; 0.8 and later pass the agent's tenant.Related work
This is the Python part of a three-SDK change:
RealtimeProtection.Evaluate.All) as part ofa365 setup all.