diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2b2eb14..4ee34c3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,7 +22,12 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
**Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output.
+#### Existing agents: grant Defender API permissions
+
+Agents provisioned before this release need `RealtimeProtection.Process` granted as both a **delegated** and an **application** permission on the blueprint app for the Defender security integration. Requires Global Administrator. Follow the steps above, searching for `86a21212-634e-4553-b3d6-e477e4c9d9ec` in step 2 and selecting `RealtimeProtection.Process` in steps 3 and 4. Re-running `a365 setup all` grants it automatically.
+
### Added
+- `RealtimeProtection.Process` on the Defender API is now granted automatically during `a365 setup` as both a delegated and an application permission, enabling the Microsoft Defender security integration without manual Entra steps.
- Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text.
- Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials.
- `a365 develop-mcp evaluate` command for evaluating MCP server tool schema quality — runs deterministic and semantic checks (via GitHub Copilot or Claude Code CLIs), computes maturity scoring, and generates an interactive HTML report
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs
index 46ad66c9..c544afbf 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs
@@ -616,6 +616,7 @@ private static Command CreateInstanceScopesSubcommand(
AuthenticationConstants.MicrosoftGraphResourceAppId => "Microsoft Graph",
ConfigConstants.MessagingBotApiAppId => "Messaging Bot API",
ConfigConstants.ObservabilityApiAppId => "Observability API",
+ ConfigConstants.DefenderApiAppId => "Defender API",
PowerPlatformConstants.PowerPlatformApiResourceAppId => "Power Platform API",
"00000002-0000-0000-c000-000000000000" => "Azure Active Directory Graph",
"797f4846-ba00-4fd7-ba43-dac1f8f63013" => "Azure Service Management",
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs
index 9a6150cc..e5b5e10b 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs
@@ -20,8 +20,8 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
/// 1. Requirements validation
/// 2. Blueprint creation (shared with DW)
/// 3. Batch permissions on the blueprint (shared with DW pipeline; non-DW spec set:
-/// Observability API, Power Platform API, custom). MAC reads from the blueprint,
-/// so stamping here gives the same set visibility there.
+/// Observability API, Defender API, Power Platform API, custom). MAC reads
+/// from the blueprint, so stamping here gives the same set visibility there.
/// 4. Agent Identity creation via POST /beta/servicePrincipals/Microsoft.Graph.AgentIdentity
/// 5. Agent Identity permission grants (same spec set as step 3) — OBO or S2S
/// 6. Agent registration via Graph API (copilot/agentRegistrations)
@@ -117,14 +117,15 @@ public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool i
logger.LogInformation(sub + "create managed identity");
}
- // 3. Inheritable Permissions — non-DW spec set (Observability API, Power Platform API, custom)
- // stamped on the blueprint via SetInheritablePermissionsAsync so MAC and other dependent
- // systems can see them. The same set is applied to the agent identity SP in step 5.
+ // 3. Inheritable Permissions — non-DW spec set (Observability API, Defender API,
+ // Power Platform API, custom) stamped on the blueprint via SetInheritablePermissionsAsync
+ // so MAC and other dependent systems can see them. The same set is applied to the agent
+ // identity SP in step 5.
var selectedAuthMode = authMode ?? config.AuthMode;
var effectiveMode = string.IsNullOrWhiteSpace(selectedAuthMode)
? "obo"
: selectedAuthMode.Trim().ToLowerInvariant();
- logger.LogInformation(SetupHelpers.DryRunRow(3, "Inheritable Permissions") + "configure for Observability API, Power Platform API, and custom permissions (Global Administrator required; consent URL printed if absent)");
+ logger.LogInformation(SetupHelpers.DryRunRow(3, "Inheritable Permissions") + "configure for Observability API, Defender API, Power Platform API, and custom permissions (Global Administrator required; consent URL printed if absent)");
// 4. Blueprint Permission Grants — per authMode. The consent URL targets the blueprint
// app, and S2S app-role assignments are persisted as grants flowing from the blueprint;
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs
index c1b4aaea..0f32b988 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs
@@ -310,6 +310,7 @@ private static Command CreateBotSubcommand(
logger.LogInformation(" - Blueprint: {BlueprintId}", dryRunConfig.AgentBlueprintId);
logger.LogInformation(" - Messaging Bot API: {Scope}", ConfigConstants.MessagingBotApiAdminConsentScope);
logger.LogInformation(" - Observability API: {OtelScope} (delegated + application)", ConfigConstants.ObservabilityApiOtelWriteScope);
+ logger.LogInformation(" - Defender API: {DefenderScope} (delegated + application)", ConfigConstants.DefenderApiRealtimeProtectionScope);
logger.LogInformation(" - Power Platform API: Connectivity.Connections.Read");
logger.LogInformation("No changes made. Run without --dry-run to execute.");
return;
@@ -852,6 +853,7 @@ internal static async Task RemoveStaleCustomPermissionsAsync(
envAtgAppId,
ConfigConstants.MessagingBotApiAppId,
ConfigConstants.ObservabilityApiAppId,
+ ConfigConstants.DefenderApiAppId,
PowerPlatformConstants.PowerPlatformApiResourceAppId,
AuthenticationConstants.MicrosoftGraphResourceAppId,
};
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs
index 53e85506..0f0e69a8 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs
@@ -49,9 +49,9 @@ internal static void PrintDryRunBlueprintReuseRows(ILogger logger, string bluepr
/// Returns the fixed-scope ResourcePermissionSpecs for the platform APIs that every
/// agent blueprint requires.
///
- /// Observability API and Power Platform API are always included. Messaging Bot API is
- /// included only when is true — non-M365 (blueprint-only) agents
- /// have no messaging surface so Bot scopes serve no purpose.
+ /// Observability API, Defender API, and Power Platform API are always included.
+ /// Messaging Bot API is included only when is true — non-M365
+ /// (blueprint-only) agents have no messaging surface so Bot scopes serve no purpose.
///
///
internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInheritable, bool isM365)
@@ -79,6 +79,12 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInhe
new[] { ConfigConstants.ObservabilityApiOtelWriteScope },
setInheritable,
AppRoleScopes: new[] { ConfigConstants.ObservabilityApiOtelWriteScope }));
+ specs.Add(new ResourcePermissionSpec(
+ ConfigConstants.DefenderApiAppId,
+ "Defender API",
+ new[] { ConfigConstants.DefenderApiRealtimeProtectionScope },
+ setInheritable,
+ AppRoleScopes: new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }));
specs.Add(new ResourcePermissionSpec(
PowerPlatformConstants.PowerPlatformApiResourceAppId,
"Power Platform API",
@@ -362,8 +368,8 @@ internal static async Task> BuildConfiguredPermissi
///
/// Fixed permission specs for the non-DW admin consent flow.
- /// Observability API requires both Application (app role for S2S) and Delegated (oauth2 grant for OBO).
- /// Power Platform API requires Delegated only.
+ /// Observability API and Defender API require both Application (app role for S2S)
+ /// and Delegated (oauth2 grant for OBO). Power Platform API requires Delegated only.
/// Extend this list or pass an override to
/// when additional APIs are required (e.g. dynamic MCP scopes, custom permissions).
///
@@ -371,14 +377,26 @@ internal static async Task> BuildConfiguredPermissi
[
("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Application"),
("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Delegated"),
+ ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Application"),
+ ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Delegated"),
("Power Platform API", PowerPlatformConstants.PowerPlatformApiResourceAppId, PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead, "Delegated"),
];
+ ///
+ /// Fixed platform APIs that expose an application (S2S) app role, used to render the manual
+ /// PowerShell hand-off when the programmatic assignment could not complete.
+ ///
+ internal static readonly IReadOnlyList<(string ResourceName, string ResourceAppId, string Role)> FixedApiAppRoleHandoffSpecs =
+ [
+ ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope),
+ ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope),
+ ];
+
///
/// Logs step-by-step instructions for a Global Administrator to grant admin consent
/// for the blueprint app, with two options: Entra portal and PowerShell.
///
- /// Defaults to (Observability API + Power Platform API).
+ /// Defaults to (Observability, Defender, and Power Platform APIs).
/// Pass an explicit list to support dynamic or extended permission sets.
///
///
@@ -831,7 +849,7 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger)
{
actionCount++;
logger.LogInformation("");
- logger.LogInformation(" {N}. Observability API S2S app role (PowerShell):", actionCount);
+ logger.LogInformation(" {N}. Application (S2S) app roles (PowerShell):", actionCount);
logger.LogInformation(" Required role: {Roles}", AuthenticationConstants.S2SGrantRequiredRoles);
if (!string.IsNullOrWhiteSpace(results.TenantId))
logger.LogInformation(" Connect-MgGraph -TenantId '{TenantId}' -Scopes 'AppRoleAssignment.ReadWrite.All','Application.Read.All'", results.TenantId);
@@ -844,9 +862,14 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger)
// Grant targets the agent identity SP directly (SP object ID, not an app ID).
var agentSpId = results.AgentIdentityId ?? "";
logger.LogInformation(" $agentSpId = '{AgentSpId}'", agentSpId);
- logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", ConfigConstants.ObservabilityApiAppId);
- logger.LogInformation(" $rid = ($obs.AppRoles | Where-Object {{ $_.Value -eq '{ObsScope}' }}).Id", ConfigConstants.ObservabilityApiOtelWriteScope);
- logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $agentSpId -PrincipalId $agentSpId -ResourceId $obs.Id -AppRoleId $rid");
+ foreach (var (resourceName, resourceAppId, role) in FixedApiAppRoleHandoffSpecs)
+ {
+ logger.LogInformation("");
+ logger.LogInformation(" # {ResourceName}: {Role}", resourceName, role);
+ logger.LogInformation(" $res = Get-MgServicePrincipal -Filter \"appId eq '{ResAppId}'\"", resourceAppId);
+ logger.LogInformation(" $rid = ($res.AppRoles | Where-Object {{ $_.Value -eq '{Role}' }}).Id", role);
+ logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $agentSpId -PrincipalId $agentSpId -ResourceId $res.Id -AppRoleId $rid");
+ }
logger.LogInformation("");
if (!string.IsNullOrWhiteSpace(results.TenantId))
logger.LogInformation(" Tenant : {TenantId}", results.TenantId);
@@ -856,9 +879,14 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger)
{
// DW: grant targets the blueprint SP (looked up by app ID).
logger.LogInformation(" $bp = Get-MgServicePrincipal -Filter \"appId eq '{BlueprintAppId}'\"", blueprintAppId);
- logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", ConfigConstants.ObservabilityApiAppId);
- logger.LogInformation(" $rid = ($obs.AppRoles | Where-Object {{ $_.Value -eq '{ObsScope}' }}).Id", ConfigConstants.ObservabilityApiOtelWriteScope);
- logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $bp.Id -PrincipalId $bp.Id -ResourceId $obs.Id -AppRoleId $rid");
+ foreach (var (resourceName, resourceAppId, role) in FixedApiAppRoleHandoffSpecs)
+ {
+ logger.LogInformation("");
+ logger.LogInformation(" # {ResourceName}: {Role}", resourceName, role);
+ logger.LogInformation(" $res = Get-MgServicePrincipal -Filter \"appId eq '{ResAppId}'\"", resourceAppId);
+ logger.LogInformation(" $rid = ($res.AppRoles | Where-Object {{ $_.Value -eq '{Role}' }}).Id", role);
+ logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $bp.Id -PrincipalId $bp.Id -ResourceId $res.Id -AppRoleId $rid");
+ }
logger.LogInformation("");
logger.LogInformation(" To share with your {Roles}:", AuthenticationConstants.S2SGrantRequiredRoles);
logger.LogInformation(" Blueprint : {BlueprintAppId}", blueprintAppId);
@@ -883,6 +911,11 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger)
logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $obsSp.Id; scope = '{ObsScope}' }} | ConvertTo-Json", ConfigConstants.ObservabilityApiOtelWriteScope);
logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'");
logger.LogInformation("");
+ logger.LogInformation(" # Defender API");
+ logger.LogInformation(" $defenderSp = Get-MgServicePrincipal -Filter \"appId eq '{DefenderAppId}'\"", ConfigConstants.DefenderApiAppId);
+ logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $defenderSp.Id; scope = '{DefenderScope}' }} | ConvertTo-Json", ConfigConstants.DefenderApiRealtimeProtectionScope);
+ logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'");
+ logger.LogInformation("");
logger.LogInformation(" # Power Platform API");
logger.LogInformation(" $ppSp = Get-MgServicePrincipal -Filter \"appId eq '{PpAppId}'\"", PowerPlatformConstants.PowerPlatformApiResourceAppId);
logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $ppSp.Id; scope = '{PpScope}' }} | ConvertTo-Json", PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead);
@@ -1076,6 +1109,7 @@ internal static List PopulateAdminConsentUrls(
["Agent 365 Tools"] = mcpResourceAppId,
["Messaging Bot API"] = ConfigConstants.MessagingBotApiAppId,
["Observability API"] = ConfigConstants.ObservabilityApiAppId,
+ ["Defender API"] = ConfigConstants.DefenderApiAppId,
["Power Platform API"] = PowerPlatformConstants.PowerPlatformApiResourceAppId,
};
@@ -1177,6 +1211,8 @@ internal static string GetResourceIdentifierUri(string resourceAppId, bool isMcp
return ConfigConstants.MessagingBotApiIdentifierUri;
if (string.Equals(resourceAppId, ConfigConstants.ObservabilityApiAppId, StringComparison.OrdinalIgnoreCase))
return ConfigConstants.ObservabilityApiIdentifierUri;
+ if (string.Equals(resourceAppId, ConfigConstants.DefenderApiAppId, StringComparison.OrdinalIgnoreCase))
+ return ConfigConstants.DefenderApiIdentifierUri;
if (string.Equals(resourceAppId, PowerPlatformConstants.PowerPlatformApiResourceAppId, StringComparison.OrdinalIgnoreCase))
return PowerPlatformConstants.PowerPlatformApiIdentifierUri;
// WorkIQ Tools shared (issue #429): match by appId, not display name. V2 per-server
@@ -1316,6 +1352,7 @@ static string Build(string tenant, string client, string resourceUri, IEnumerabl
urls.Add(("Messaging Bot API", Build(tenantId, blueprintClientId, ConfigConstants.MessagingBotApiIdentifierUri, new[] { ConfigConstants.MessagingBotApiAdminConsentScope })));
urls.Add(("Observability API", Build(tenantId, blueprintClientId, ConfigConstants.ObservabilityApiIdentifierUri, new[] { ConfigConstants.ObservabilityApiOtelWriteScope })));
+ urls.Add(("Defender API", Build(tenantId, blueprintClientId, ConfigConstants.DefenderApiIdentifierUri, new[] { ConfigConstants.DefenderApiRealtimeProtectionScope })));
urls.Add(("Power Platform API", Build(tenantId, blueprintClientId, PowerPlatformConstants.PowerPlatformApiIdentifierUri, new[] { PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead })));
return urls;
@@ -1371,6 +1408,7 @@ internal static string BuildCombinedConsentUrl(
if (isM365)
allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}");
allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}");
+ allScopes.Add($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}");
allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}");
return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes);
}
@@ -1454,7 +1492,7 @@ internal static void PrintDwSetupAllDryRunPlan(
}
// 4. Inheritable Permissions
- logger.LogInformation(DryRunRow(4, "Inheritable Permissions") + "configure for Microsoft Graph, Agent 365 Tools, Messaging Bot API, Observability API, Power Platform API");
+ logger.LogInformation(DryRunRow(4, "Inheritable Permissions") + "configure for Microsoft Graph, Agent 365 Tools, Messaging Bot API, Observability API, Defender API, Power Platform API");
// 5. Blueprint Permission Grants
logger.LogInformation(DryRunRow(5, "Blueprint Permission Grants") + "admin approval required — see 'Action Required' in setup output");
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs
index bf2fe665..6ea1deab 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs
@@ -80,6 +80,16 @@ public static class ConfigConstants
///
public const string ObservabilityApiIdentifierUri = "api://9b975845-388f-4429-889e-eab1ef63949c";
+ ///
+ /// Defender API App ID
+ ///
+ public const string DefenderApiAppId = "86a21212-634e-4553-b3d6-e477e4c9d9ec";
+
+ ///
+ /// Defender API identifier URI.
+ ///
+ public const string DefenderApiIdentifierUri = "https://rtp-a365.ai.defender.microsoft.com";
+
///
/// Single source of truth for the Messaging Bot API delegated scope.
/// The resource SP (appId 5a807f24-c9de-44ee-a3a7-329e88a00ffc) exposes exactly
@@ -97,6 +107,12 @@ public static class ConfigConstants
///
public const string ObservabilityApiOtelWriteScope = "Agent365.Observability.OtelWrite";
+ ///
+ /// Defender API app role and delegated scope for the Defender security integration.
+ /// Must match the value published on the resource SP.
+ ///
+ public const string DefenderApiRealtimeProtectionScope = "RealtimeProtection.Process";
+
///
/// Delegated scope value exposed on the blueprint app registration to enable
/// OBO (On-Behalf-Of) callers to acquire tokens scoped to the agent.
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs
index cf678914..b3d09442 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs
@@ -59,6 +59,7 @@ public sealed class LogRedactionService : ILogRedactionService
"00000003-0000-0000-c000-000000000000", // Microsoft Graph
"5a807f24-c9de-44ee-a3a7-329e88a00ffc", // Agent 365 Messaging Bot API
"9b975845-388f-4429-889e-eab1ef63949c", // Agent 365 Observability API
+ "86a21212-634e-4553-b3d6-e477e4c9d9ec", // Agent 365 Defender API
"8578e004-a5c6-46e7-913e-12f58912df43", // Power Platform API (Connectivity)
"ea9ffc3e-8a23-4a7d-836d-234d7c7565c1", // Agent 365 Tools (MCP audience, production)
};
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs
index 674d6a52..9f5727fc 100644
--- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs
@@ -89,9 +89,10 @@ public async Task DwPath_NoManifest_NoCustom_ProducesBaselineSpecSet()
AuthenticationConstants.MicrosoftGraphResourceAppId,
ConfigConstants.MessagingBotApiAppId,
ConfigConstants.ObservabilityApiAppId,
+ ConfigConstants.DefenderApiAppId,
PowerPlatformConstants.PowerPlatformApiResourceAppId,
McpConstants.WorkIQToolsProdAppId,
- }, because: "the DW baseline spec set is the four fixed platform APIs plus the ATG AppId (seeded with McpServersMetadata.Read.All for V1 compatibility)");
+ }, because: "the DW baseline spec set is the five fixed platform APIs (including the Defender API required by the security integration) plus the ATG AppId (seeded with McpServersMetadata.Read.All for V1 compatibility)");
// Assert: ATG entry carries only the seeded V1-compat scope when no manifest is present.
SpecFor(specs, McpConstants.WorkIQToolsProdAppId).Scopes.Should().BeEquivalentTo(new[] { McpServersMetadataReadAll },
@@ -260,6 +261,7 @@ public async Task Unified_WithManifest_IsM365_StampsFullSet()
AuthenticationConstants.MicrosoftGraphResourceAppId,
ConfigConstants.MessagingBotApiAppId,
ConfigConstants.ObservabilityApiAppId,
+ ConfigConstants.DefenderApiAppId,
PowerPlatformConstants.PowerPlatformApiResourceAppId,
McpConstants.WorkIQToolsProdAppId,
}, because: "with a manifest present and isM365 true, blueprint agents must receive the same spec set as DW agents — this is the unified-pipeline contract");
@@ -286,6 +288,34 @@ public async Task ObservabilityApi_CarriesBothDelegatedScopeAndAppRole()
because: "Observability API app role grants OtelWrite for the s2s path — losing either side breaks one auth mode");
}
+ [Fact]
+ public async Task DefenderApi_CarriesBothDelegatedScopeAndAppRole()
+ {
+ // Arrange: smallest config that produces the Defender spec on either path.
+ var config = new Agent365Config { DeploymentProjectPath = _tempDir };
+
+ // Act
+ var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync(config, setInheritable: true, isM365: true);
+
+ // Assert
+ var defender = SpecFor(specs, ConfigConstants.DefenderApiAppId);
+ defender.Scopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiRealtimeProtectionScope },
+ because: "the Defender API delegated scope grants RealtimeProtection.Process for the OBO path");
+ defender.AppRoleScopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiRealtimeProtectionScope },
+ because: "the Defender API app role grants RealtimeProtection.Process for the s2s path — the Defender webhook rejects tokens without the roles claim, so losing either side breaks one auth mode");
+ defender.SetInheritable.Should().BeTrue(
+ because: "agent identities minted from the blueprint must inherit the Defender permission, exactly as they do for OtelWrite");
+ }
+
+ [Fact]
+ public void DefenderApi_ScopeValue_MatchesValuePublishedOnResource()
+ {
+ // A value the resource SP does not publish fails the combined consent URL for every
+ // resource in it (AADSTS650053), not just Defender.
+ ConfigConstants.DefenderApiRealtimeProtectionScope.Should().Be("RealtimeProtection.Process",
+ because: "this is the app role and delegated scope value published on the Defender resource SP; changing it without a matching resource-side change fails admin consent tenant-wide");
+ }
+
[Fact]
public async Task MessagingBotApi_UsesScopeConstantSoSpecAndConsentUrlAgree()
{
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs
index 4e8d61e1..7bbd0a75 100644
--- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs
@@ -61,6 +61,22 @@ public void LogNonDwAdminConsentInstructions_OptionA_ShowsDelegatedPermissionsIn
because: "only delegated grants are needed for OBO — no Application permissions");
}
+ [Fact]
+ public void LogNonDwAdminConsentInstructions_OptionA_ShowsDefenderDelegatedPermission()
+ {
+ var logger = new CapturingLogger();
+
+ SetupHelpers.LogNonDwAdminConsentInstructions(logger, BlueprintId);
+
+ var defenderLines = logger.Messages
+ .Where(m => m.Contains("Defender API") && m.Contains(ConfigConstants.DefenderApiRealtimeProtectionScope))
+ .ToList();
+ defenderLines.Should().HaveCount(1,
+ because: "the Defender API delegated scope must appear exactly once so the admin grants it alongside the other platform APIs");
+ defenderLines[0].Should().Contain("Delegated",
+ because: "only delegated grants are needed for OBO — no Application permissions");
+ }
+
[Fact]
public void LogNonDwAdminConsentInstructions_DoesNotEmitOptionBPowerShell()
{
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs
index 9607bbb4..023c71d7 100644
--- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs
@@ -26,13 +26,14 @@ public void BuildAdminConsentUrls_WithGraphAndMcpScopes_ReturnsUrlForEachResourc
var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, graphScopes, mcpScopes);
- urls.Should().HaveCount(5);
+ urls.Should().HaveCount(6);
urls.Select(u => u.ResourceName).Should().Contain(new[]
{
"Microsoft Graph",
"Agent 365 Tools",
"Messaging Bot API",
"Observability API",
+ "Defender API",
"Power Platform API"
});
}
@@ -72,6 +73,18 @@ public void BuildAdminConsentUrls_ObservabilityApi_UsesCorrectScopeConstant()
because: "OtelWrite is the published delegated scope on the Observability API used for admin consent");
}
+ [Fact]
+ public void BuildAdminConsentUrls_DefenderApi_UsesHttpsIdentifierUriNotApiScheme()
+ {
+ var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" });
+ var defenderUrl = urls.First(u => u.ResourceName == "Defender API").ConsentUrl;
+
+ defenderUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"),
+ because: "the Defender resource publishes only the https identifier URI — api://{appId} is not in its servicePrincipalNames and consent fails with AADSTS500011");
+ defenderUrl.Should().NotContain(Uri.EscapeDataString($"api://{ConfigConstants.DefenderApiAppId}"),
+ because: "the api:// form of the Defender resource is not a registered servicePrincipalName");
+ }
+
[Fact]
public void BuildAdminConsentUrls_PowerPlatformApi_UsesCorrectScopeConstant()
{
@@ -211,9 +224,9 @@ public void BuildCombinedConsentUrl_IncludesAllMcpScopes()
}
[Fact]
- public void BuildCombinedConsentUrl_AlwaysIncludesAllThreeFixedResources()
+ public void BuildCombinedConsentUrl_AlwaysIncludesAllFixedResources()
{
- // Even with empty graph and MCP scopes, the three fixed resources must be present
+ // Even with empty graph and MCP scopes, the fixed resources must be present
var url = SetupHelpers.BuildCombinedConsentUrl(
TenantId, BlueprintClientId,
Array.Empty(), Array.Empty());
@@ -222,6 +235,8 @@ public void BuildCombinedConsentUrl_AlwaysIncludesAllThreeFixedResources()
because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent");
url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"),
because: "OtelWrite is the published delegated scope on the Observability API used for admin consent");
+ url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"),
+ because: "RealtimeProtection.Process is the published delegated scope on the Defender API — without it the agent cannot call the Defender security webhook");
url.Should().Contain(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"));
}
@@ -264,8 +279,9 @@ public void BuildAdminConsentUrls_NonM365_ExcludesMessagingBotButKeepsAllOthers(
"Microsoft Graph",
"Agent 365 Tools",
"Observability API",
+ "Defender API",
"Power Platform API",
- }, because: "non-M365 tenants lack the Messaging Bot resource SP — Bot would cause AADSTS650053 if included");
+ }, because: "non-M365 tenants lack the Messaging Bot resource SP — Bot would cause AADSTS650053 if included; the Defender API is required for the security integration on every agent regardless of M365 surface");
}
[Fact]