Skip to content

Add Microsoft Defender for AI real-time protection on agent-hooks - #278

Open
Krishnadheeraj (DheerajPannala) wants to merge 12 commits into
mainfrom
kpannala-microsoft-python-defender-rtp-agent-hooks
Open

Krishnadheeraj (DheerajPannala) wants to merge 12 commits into
mainfrom
kpannala-microsoft-python-defender-rtp-agent-hooks

Conversation

@DheerajPannala

@DheerajPannala Krishnadheeraj (DheerajPannala) commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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 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.

  1. 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.
  2. 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.
  3. 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.

defender = DefenderRtpClient(DefenderRtpOptions.from_environment())
tokens = DefenderRtpTokenResolvers.from_agentic_connection(
    connection_manager.get_default_connection()
)
activity = turn_context.activity
agent = DefenderRtpAgentContext(
    agent_id=activity.get_agentic_instance_id(),  # the agent identity
    tenant_id=activity.get_agentic_tenant_id(),  # the agent's tenant
)

emitter = add_a365_defender(
    create_protection_emitter(defender=defender.options),
    A365DefenderInterceptor(defender, lambda context: A365DefenderCall(agent, tokens)),
)
record = await emitter.emit_unchecked(builder.input(content=user_message))

Testing

  • 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.
  • The Defender permission needs the CLI change. a365 setup grants 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).
  • 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.

Related work

This is the Python part of a three-SDK change:

…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
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
pip/agent-hooks-sdk 0.1.0a5 UnknownUnknown

Scanned Files

  • uv.lock

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Security configuration mutability and request-normalization defects can bypass or invalidate Defender evaluation.

4 open findings
What changed in this PR

Adds opt-in Microsoft Defender for AI real-time protection through a core tooling client and a new agent-hooks extension.

Changes:

  • Implements Defender request normalization, authentication, caching, verdicts, and fail modes.
  • Adds agent-hooks interception and workspace packaging.
  • Adds comprehensive tests and documentation.
File Description
uv.lock Locks the new package and dependencies.
tests/​tooling/​protection/​test_defender_rtp_token_resolvers.py Tests token exchange behavior.
tests/​tooling/​protection/​test_defender_rtp_options.py Tests configuration parsing and validation.
tests/​tooling/​protection/​test_defender_rtp_client.py Tests client requests, verdicts, failures, and caching.
tests/​tooling/​protection/​defender_fakes.py Provides Defender test doubles.
tests/​tooling/​protection/​__init__.py Initializes protection tests.
tests/​tooling/​extensions/​agenthooks/​test_a365_defender_interceptor.py Tests interceptor integration.
tests/​tooling/​extensions/​agenthooks/​__init__.py Initializes agent-hooks tests.
tests/​test_published_security_constraints.py Registers package security constraints.
tests/​test_dependency_constraints.py Registers dependency checks.
tests/​RUNNING_TESTS.md Adds extension installation instructions.
README.md Lists the new package.
pyproject.toml Registers workspace package and dependency.
libraries/​microsoft-agents-a365-tooling/​pyproject.toml Declares aiohttp.
libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_token_resolvers.py Implements agent identity token exchange.
libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_options.py Defines Defender configuration.
libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_evaluation_result.py Defines evaluation models.
libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py Implements Defender evaluation.
libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_agent_context.py Defines agent and token context.
libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​_http.py Manages HTTP sessions.
libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​__init__.py Exports Defender APIs.
libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​__init__.py Introduces protection namespace.
libraries/​microsoft-agents-a365-tooling/​docs/​design.md Documents Defender architecture.
libraries/​microsoft-agents-a365-tooling/​CHANGELOG.md Records core tooling additions.
libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​setup.py Configures package versioning.
libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​README.md Documents setup and usage.
libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​pyproject.toml Defines package metadata.
libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​microsoft_agents_a365/​tooling/​extensions/​agenthooks/​a365_defender_interceptor.py Maps Defender results to hook verdicts.
libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​microsoft_agents_a365/​tooling/​extensions/​agenthooks/​a365_agent_hooks.py Creates and registers emitters.
libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​microsoft_agents_a365/​tooling/​extensions/​agenthooks/​__init__.py Exports extension APIs.
libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​docs/​design.md Documents extension design.
libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​CHANGELOG.md Records the new package.
docs/​design.md Adds package architecture references.
DEPENDENCIES.md Updates the dependency graph.
CLAUDE.md Adds the package to repository guidance.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

- 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Redirect handling and several context/configuration invariants can leak sensitive data or produce incorrect protection decisions.

10 open findings

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread tests/tooling/protection/test_defender_rtp_client.py Outdated
Comment thread libraries/microsoft-agents-a365-tooling/CHANGELOG.md Outdated
- 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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 12:31
- 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Redirect handling can expose sensitive request bodies, while low content limits can invalidate Defender contract fields and fail open.

0 open findings

10 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Generic clamping corrupts schema-constrained metadata

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py:390

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.

Copilot AI balanced review requested due to automatic review settings October 8, 2026 12:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Content clamping can invalidate identity metadata and valid agent-hooks fields are discarded.

0 open findings

Previously missed (2)

In code that hasn't changed since last review

Medium severity Rebuilding objects drops valid content hash and duration fields

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py:367

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.

Medium severity Content clamping truncates tenant and agent identity IDs

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py:389

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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 12:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Numeric validation and low content limits can disable deadlines, hang truncation, or invalidate Defender requests.

1 open finding
Previously missed (1)

In code that hasn't changed since last review

Medium severity Reject non-finite timeout values

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_options.py:138

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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 14:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Dependency compatibility, Unicode truncation, and option validation issues remain unresolved.

3 open findings
1 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 14:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The bounded sequence cache can violate the agent-hooks ordering contract for revisited sessions.

2 open findings
2 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Prevent sequence reset for live sessions after cache eviction

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py:799

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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 14:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Synchronous resolver execution and unbounded tool/message traversal can defeat the promised deadline and block the event loop.

0 open findings

3 resolved since last review
Previously missed (3)

In code that hasn't changed since last review

Medium severity Synchronous token resolver bypasses timeout and blocks event loop

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py:763

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.

Medium severity Unbounded tool-list scan and copy delays Defender evaluation

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py:977

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.

Medium severity Full message pre-pass causes unbounded synchronous preparation

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py:1020

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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 14:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Surrogate-pair truncation reports an incorrect omitted-character count, and sequence documentation overstates monotonicity.

1 open finding
Previously missed (1)

In code that hasn't changed since last review

Medium severity Truncated count miscalculates normalized surrogate pairs

libraries/​microsoft-agents-a365-tooling/​microsoft_agents_a365/​tooling/​protection/​defender/​defender_rtp_client.py:988

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.

Comment thread libraries/microsoft-agents-a365-tooling/docs/design.md Outdated
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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 15:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Reserved warning handling, unsafe fail-mode parsing, unbounded envelope copying, and callback/error propagation can alter enforcement or expose data.

5 open findings
1 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

…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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 16:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unbounded response buffering, suffix scanning, and lingering resolver threads create unresolved resource-exhaustion risks.

4 open findings
5 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

…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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 16:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The extension documentation incorrectly states that non-finite values reach Defender despite agent-hooks rejecting them before interceptor dispatch.

0 open findings

4 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Low severity Document non-finite number limitation in emitter path

libraries/​microsoft-agents-a365-tooling-extensions-agenthooks/​README.md:191

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.

🧠 Review effort: Balanced

@DheerajPannala
Krishnadheeraj (DheerajPannala) added this pull request to stack #281 October 9, 2026 12:34
Krishnadheeraj (DheerajPannala) added a commit that referenced this pull request Oct 9, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants