Skip to content

Control 1.8 — PowerShell Setup: Runtime Protection and External Threat Detection

Control: 1.8 Runtime Protection and External Threat Detection Baseline: PowerShell baseline (_shared/powershell-baseline.md) Audience: M365 administrator at a US financial services organization (FINRA / SEC / GLBA / OCC / Fed SR 11-7 / CFTC oversight) operating Microsoft 365 Copilot, Agent Builder, and Copilot Studio agents. Sovereign clouds: Commercial / GCC / GCC High / DoD — connection helper in Section 1 and full reference in Section 11. Several Control 1.8 surfaces (Defender for Cloud Apps AI Agent Protection, Additional Threat Detection webhooks) are Preview or Prerelease in commercial cloud and have not been documented at parity for US Government clouds — see Section 11 and Section 12.

Required modules:

  • Microsoft.PowerApps.Administration.PowerShell — pinned per CAB. Windows PowerShell 5.1 (Desktop edition) only, per PowerShell baseline § 2. Provides Add-PowerAppsAccount, Get-AdminPowerAppEnvironment, Get-DlpPolicy.
  • Microsoft.Graph.Authentication ≥ 2.15.0 — provides Connect-MgGraph and Invoke-MgGraphRequest for application registration, federated identity credential creation, Defender XDR alerts, and Microsoft Sentinel hunting queries against the beta endpoint.
  • Microsoft.Graph.Applications ≥ 2.15.0 — typed cmdlets New-MgApplication, Get-MgApplication, New-MgApplicationFederatedIdentityCredential, Remove-MgApplicationFederatedIdentityCredential, Remove-MgApplication.
  • Microsoft.Graph.Beta.Security ≥ 2.15.0 — typed cmdlet Get-MgBetaSecurityAlert_v2 and supporting types for Defender XDR AI agent alerts; this playbook also calls the raw Invoke-MgGraphRequest against /beta/security/runHuntingQuery to keep schema stable as the surface evolves.
  • ExchangeOnlineManagement ≥ 3.5.0 — provides Connect-IPPSSession and Search-UnifiedAuditLog (paged) for the Copilot Studio and Microsoft 365 Copilot audit families.

Read the FSI PowerShell baseline first

Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), transcript capture, and SHA-256 evidence emission. Snippets below assume you have already complied with that baseline.

READ FIRST — Control 1.8 has FOUR surfaces, TWO portals, and a hard PowerShell ceiling

Control 1.8 — Runtime Protection and External Threat Detection — covers four distinct runtime-security surfaces for Copilot Studio agents:

  1. Native Microsoft Defender for Cloud Apps AI Agent ProtectionMicrosoft Defender — Copilot Studio AI Agents toggle in the Power Platform Admin Center plus AI agent inventory, activity logging, and real-time protection in the Microsoft Defender XDR portal. Per Defender for Cloud Apps — AI Agent Protection, this surface is portal-only for configuration; PowerShell coverage is read-only via the Microsoft Graph beta security alerts endpoint and Microsoft Sentinel KQL.
  2. Additional Threat Detection — third-party security webhooks — Power Platform Admin Center → Security → Threat protection → Additional threat detection. Configured by pasting the App ID of an Entra app registration with a federated identity credential that PPAC trusts to call out to your security provider on every Copilot Studio agent invocation. Per Configure an external security provider, the FIC subject and issuer values are issued by the Power Platform service and copied from the PPAC UI by the operator — they are not constructible by client-side script.
  3. Prompt Shields and content moderation (Azure AI Content Safety) — per-agent settings in Copilot Studio Maker at Settings → Generative AI → Content moderation. There is no Microsoft-supported PowerShell cmdlet that creates, updates, or reads per-agent moderation level. Evidence comes from the audit log (PromptInjectionDetected, ContentSafetyBlock) and from solution-export inspection.
  4. Egress / DLP guardrailsGet-DlpPolicy, New-DlpPolicy, Set-DlpPolicy for environment-scoped DLP that constrains the connectors Copilot Studio agents can reach at runtime. This is the only Control 1.8 surface with full PowerShell CRUD, and it is shared with Control 1.4 — Advanced Connector Policies (ACP).

What PowerShell can do for Control 1.8:

  • Pre-flight role / license / module / sovereign-endpoint / Managed-Environments / M365 App Connector status.
  • Inventory environments (with the correct managed-environment property path), webhook FIC bindings on the registered app, and DLP policies that govern connector egress.
  • Mutate the Entra app registration and federated identity credential that backs Additional Threat Detection — but only after the operator has copied the PPAC-issued subject and issuer from the portal.
  • Collect audit-log evidence (Search-UnifiedAuditLog, paged) across two RecordType families that surface Copilot Studio runtime threat events, and reconcile them.
  • Read Defender XDR AI agent alerts (/beta/security/alerts_v2), and run Microsoft Sentinel KQL with the correct column name (EventOriginalType, not Operation) on PowerPlatformAdminActivity.
  • Roll back partial app-registration creation so a half-configured app does not become orphaned tenant debt. What PowerShell cannot do for Control 1.8:

  • Toggle the Microsoft Defender — Copilot Studio AI Agents setting in PPAC (portal-only).

  • Bind a registered app as the active Additional Threat Detection provider on an environment (portal-only — operator pastes the App ID into PPAC).
  • Create or read per-agent Prompt Shield strength, content moderation level, or jailbreak-detection toggle (Maker portal only).
  • Configure Defender XDR notification policies, AI agent inventory thresholds, or AISPM dashboard pivots (Defender XDR portal only).

Treat this playbook as the evidence, inventory, app-registration, and reconciliation automation layer for Control 1.8 — not as a substitute for portal-driven runtime-protection administration.


0. Wrong-shell trap (READ FIRST)

Control 1.8 cmdlets live across four PowerShell sessions and two PowerShell editions. Running a cmdlet in the wrong session produces either a CommandNotFoundException or, worse, silent zero results that look like clean evidence — the worst possible outcome under SEC 17a-4(f) and FINRA 4511 record-keeping expectations. Every script block in this playbook is labelled with its required session — do not strip those labels when copying.

Cmdlet family Required session Module Edition If you run it in the wrong session
Add-PowerAppsAccount, Get-AdminPowerAppEnvironment, Get-DlpPolicy, New-DlpPolicy, Set-DlpPolicy Add-PowerAppsAccount (Power Platform) Microsoft.PowerApps.Administration.PowerShell Windows PowerShell 5.1 (Desktop) only From PowerShell 7 (Core), import succeeds but cmdlets silently return null or throw schema-deserialization errors. Per PowerShell baseline § 2.
New-MgApplication, Get-MgApplication, New-MgApplicationFederatedIdentityCredential, Remove-MgApplicationFederatedIdentityCredential, Remove-MgApplication Connect-MgGraph (Graph v1.0) Microsoft.Graph.Applications PS 7.2+ (Core) From Connect-AzureAD (deprecated) or with no Graph session, throws AuthenticationException. From a Graph session without Application.ReadWrite.All, throws 403 Insufficient privileges at mutation time — no rollback unless the script catches and reverses.
Get-MgBetaSecurityAlert_v2, Invoke-MgGraphRequest -Uri /beta/security/runHuntingQuery Connect-MgGraph (Graph beta) Microsoft.Graph.Beta.Security, Microsoft.Graph.Authentication PS 7.2+ (Core) From a v1.0-only Graph session, returns 404 on beta paths and silently returns zero alerts if the typed cmdlet swallows the error.
Search-UnifiedAuditLog for Copilot Studio runtime threat events (PromptInjectionDetected, ContentSafetyBlock, JailbreakAttemptDetected, ExternalThreatDetectionCallout) Connect-IPPSSession (Security & Compliance / IPPS) ExchangeOnlineManagement Both From Connect-ExchangeOnline, the cmdlet exists but silently misses Copilot Studio operations indexed only on the IPPS endpoint.
Search-UnifiedAuditLog for Microsoft 365 Copilot user-side events (CopilotInteraction RecordType) Connect-IPPSSession (IPPS) ExchangeOnlineManagement Both Same as above. Critical: CopilotInteraction is the M365 Copilot user-prompt audit surface — it is adjacent to but distinct from the Copilot Studio runtime threat events. Both are required for full Control 1.8 evidence (see Section 5).

Always assert session and edition state at the top of every script. The Initialize-Agt18Session helper in Section 1 does this for you. The PPAC inventory script (Get-Agt18Inventory.ps1) is a separate Windows PowerShell 5.1 entrypoint — do not attempt to call it from a PowerShell 7 host.

# Session-edition guard — drop this at the top of any PPAC script
if ($PSVersionTable.PSEdition -ne 'Desktop') {
    throw "This script uses Microsoft.PowerApps.Administration.PowerShell, which requires Windows PowerShell 5.1 (Desktop edition). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion). Run from a 'Windows PowerShell' host (powershell.exe), not 'PowerShell' (pwsh.exe)."
}
# Session-edition guard — drop this at the top of any Graph / EXO / IPPS script
if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion.Major -lt 7) {
    throw "This script uses Microsoft.Graph and ExchangeOnlineManagement on PowerShell 7+. Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion). Run from a 'PowerShell' host (pwsh.exe), not 'Windows PowerShell' (powershell.exe)."
}

1. Pre-flight

Every Control 1.8 PowerShell session must start with the same nine steps:

  1. Pin module versions (CAB-approved): Microsoft.PowerApps.Administration.PowerShell, Microsoft.Graph.Authentication, Microsoft.Graph.Applications, Microsoft.Graph.Beta.Security, ExchangeOnlineManagement.
  2. Resolve sovereign-cloud connection parameters from a single -Cloud switch and emit a clear warning if the cloud is GCC / GCC High / DoD — Defender for Cloud Apps AI Agent Protection and Additional Threat Detection have not been documented at parity in those clouds.
  3. Connect to Power Platform (Desktop session only; the helper detects edition and skips the PPAC connection from Core sessions, leaving it to a sibling Desktop entrypoint).
  4. Connect to Microsoft Graph with the read / write scopes required for app-registration + FIC mutation, Defender alert read, and Sentinel hunting query execution.
  5. Connect to IPPS (Security & Compliance) — Copilot Studio audit operations are IPPS-only.
  6. Verify the caller has Power Platform Administrator (PPAC enumeration), Application Administrator or Cloud Application Administrator (Graph app + FIC mutation), and Audit Reader (UAL search) directory roles. Surface 403 / role-missing as a PRE-FLIGHT FAIL, not a silent skip.
  7. Verify the tenant has the licensing required for each surface — External Threat Detection requires Managed Environments (Power Platform Premium add-on); UAL retention beyond 180 days requires Microsoft 365 E5 Compliance or the Audit Premium add-on. Surface SKU-denied as PRE-FLIGHT FAIL.
  8. Verify the Microsoft 365 App Connector is healthy in Defender for Cloud Apps (the connector that ingests Copilot Studio agent activity into the AI Agents Inventory).
  9. Verify the named test agents exist (1.8-TEST-Agent-Z1-Control, 1.8-TEST-Agent-Z2-Control, 1.8-TEST-Agent-Z3-Control) for the canary-prompt evidence loop in Section 7.

Save the helper below as Initialize-Agt18Session.ps1 in your evidence-collection module.

#Requires -Version 7.2
#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication';  RequiredVersion = '2.15.0' }
#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Applications';    RequiredVersion = '2.15.0' }
#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Beta.Security';   RequiredVersion = '2.15.0' }
#Requires -Modules @{ ModuleName = 'ExchangeOnlineManagement';        RequiredVersion = '3.5.0' }

function Initialize-Agt18Session {
    <#
    .SYNOPSIS
        Pre-flight for Control 1.8 (Runtime Protection and External Threat Detection).
    .DESCRIPTION
        Resolves sovereign endpoint, opens a Microsoft Graph session (v1.0 + beta)
        with the scopes required for app registration, FIC mutation, Defender
        alert read, and Sentinel hunting query execution; opens an IPPS session
        for Copilot Studio audit; asserts module version pins; asserts caller
        directory-role membership; asserts tenant license surface. Read-only —
        no tenant mutation. Returns a session-context PSCustomObject for
        downstream scripts to consume.

        DOES NOT open a Power Platform Admin session — that requires Windows
        PowerShell 5.1 (Desktop edition) and is opened by the sibling Desktop
        helper Initialize-Agt18PpacSession (see Section 3).
    .PARAMETER UserPrincipalName
        UPN used to authenticate to Microsoft Graph and IPPS.
    .PARAMETER Cloud
        Microsoft 365 cloud the tenant is in. Default: Commercial.
    .PARAMETER RequiredDirectoryRoles
        Caller must be assigned at least one of these directory roles. Default
        list covers PPAC enumeration, Graph app mutation, and UAL search.
    .EXAMPLE
        $ctx = Initialize-Agt18Session -UserPrincipalName admin@contoso.com -Cloud GCCHigh
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $UserPrincipalName,
        [ValidateSet('Commercial','GCC','GCCHigh','DoD')]
        [string] $Cloud = 'Commercial',
        [string[]] $RequiredDirectoryRoles = @(
            'Power Platform Administrator',
            'Application Administrator',
            'Cloud Application Administrator',
            'Audit Reader'
        )
    )

    $ErrorActionPreference = 'Stop'

    # 1. Module pin assertion ------------------------------------------------
    $required = @(
        @{ Name = 'Microsoft.Graph.Authentication';  Min = '2.15.0' }
        @{ Name = 'Microsoft.Graph.Applications';    Min = '2.15.0' }
        @{ Name = 'Microsoft.Graph.Beta.Security';   Min = '2.15.0' }
        @{ Name = 'ExchangeOnlineManagement';        Min = '3.5.0'  }
    )
    foreach ($r in $required) {
        $m = Get-Module -ListAvailable -Name $r.Name |
             Sort-Object Version -Descending | Select-Object -First 1
        if (-not $m)                              { throw "Module $($r.Name) not installed. Install pinned version per CAB approval." }
        if ($m.Version -lt [version]$r.Min)       { throw "$($r.Name) $($m.Version) is below the $($r.Min) minimum required for Control 1.8." }
        Import-Module $r.Name -RequiredVersion $m.Version -Force | Out-Null
    }

    # 2. Resolve sovereign endpoints ----------------------------------------
    $endpoint = switch ($Cloud) {
        'Commercial' { @{
            IppsUri    = 'https://ps.compliance.protection.outlook.com/powershell-liveid/'
            Aad        = 'https://login.microsoftonline.com/organizations'
            GraphEnv   = 'Global'
            GraphHost  = 'https://graph.microsoft.com'
            PpacEndpt  = 'prod'
        } }
        'GCC'        { @{
            IppsUri    = 'https://ps.compliance.protection.outlook.com/powershell-liveid/'
            Aad        = 'https://login.microsoftonline.com/organizations'
            GraphEnv   = 'Global'
            GraphHost  = 'https://graph.microsoft.com'
            PpacEndpt  = 'usgov'
        } }
        'GCCHigh'    { @{
            IppsUri    = 'https://ps.compliance.protection.office365.us/powershell-liveid/'
            Aad        = 'https://login.microsoftonline.us/organizations'
            GraphEnv   = 'USGov'
            GraphHost  = 'https://graph.microsoft.us'
            PpacEndpt  = 'usgovhigh'
        } }
        'DoD'        { @{
            IppsUri    = 'https://l5.ps.compliance.protection.office365.us/powershell-liveid/'
            Aad        = 'https://login.microsoftonline.us/organizations'
            GraphEnv   = 'USGovDoD'
            GraphHost  = 'https://dod-graph.microsoft.us'
            PpacEndpt  = 'dod'
        } }
    }

    # 2a. Sovereign-availability warning (CRITICAL — Defender AI Agent Protection
    #     and Additional Threat Detection are documented for Commercial only;
    #     gov-cloud parity is undocumented as of February 2026)
    if ($Cloud -in @('GCC','GCCHigh','DoD')) {
        Write-Warning @"
Control 1.8 has LIMITED documented availability in $Cloud per Microsoft Learn:
  - Defender for Cloud Apps AI Agent Protection (Preview, commercial only):
    https://learn.microsoft.com/en-us/defender-cloud-apps/ai-agent-protection
  - Additional Threat Detection / Security Webhooks API (Prerelease, commercial only):
    https://learn.microsoft.com/en-us/microsoft-copilot-studio/external-security-provider
  - Copilot Studio is GA in GCC and GCC High per requirements-licensing-gcc, but
    generative-AI dependencies (Prompt Shields, content moderation) have separate
    availability constraints by cloud.
ZERO ROWS IN $Cloud DOES NOT MEAN A CLEAN TENANT.
Document the gap as a Control 1.8 exception in your control register and apply
compensating controls: Prompt Shields + content moderation (per-agent, Maker
portal), DLP for connector egress (Control 1.4), Audit Premium (Control 1.7),
Communication Compliance (Control 1.10), and Microsoft Sentinel hunting queries
against the Power Platform connector. Reverify gov-cloud availability against
Microsoft Learn before each change window.
"@
    }

    # 3. Open Microsoft Graph session (v1.0 + beta share the same connection)
    #    Scopes:
    #      - Application.ReadWrite.OwnedBy        (least-privilege for FIC scripts)
    #        OR Application.ReadWrite.All         (required for cross-owner FIC)
    #      - Directory.Read.All                    (role-membership pre-flight)
    #      - SecurityAlert.Read.All                (Defender alerts_v2)
    #      - SecurityEvents.Read.All               (Sentinel hunting query)
    #      - ThreatHunting.Read.All                (advanced hunting on /beta/security/runHuntingQuery)
    #      - Organization.Read.All                 (Get-MgSubscribedSku for license pre-flight)
    $mgCtx = Get-MgContext -ErrorAction SilentlyContinue
    $needScopes = @(
        'Application.ReadWrite.All',
        'Directory.Read.All',
        'SecurityAlert.Read.All',
        'SecurityEvents.Read.All',
        'ThreatHunting.Read.All',
        'Organization.Read.All'
    )
    if (-not $mgCtx -or $mgCtx.Environment -ne $endpoint.GraphEnv) {
        Connect-MgGraph -Environment $endpoint.GraphEnv -Scopes $needScopes -NoWelcome | Out-Null
        $mgCtx = Get-MgContext
    }

    # 4. Open IPPS session (idempotent) -------------------------------------
    $existing = Get-ConnectionInformation -ErrorAction SilentlyContinue |
                Where-Object { $_.ConnectionUri -like '*compliance.protection*' -and $_.State -eq 'Connected' }
    if (-not $existing) {
        Connect-IPPSSession `
            -UserPrincipalName $UserPrincipalName `
            -ConnectionUri $endpoint.IppsUri `
            -AzureADAuthorizationEndpointUri $endpoint.Aad | Out-Null
    }

    # 5. Directory-role membership pre-flight (caller must hold AT LEAST ONE)
    #    Per Microsoft Learn — directoryRoles graph endpoint:
    #    https://learn.microsoft.com/en-us/graph/api/directoryrole-list
    $callerId  = (Get-MgContext).Account
    $me        = Invoke-MgGraphRequest -Method GET -Uri "$($endpoint.GraphHost)/v1.0/me" -ErrorAction Stop
    $myMemb    = Invoke-MgGraphRequest -Method GET -Uri "$($endpoint.GraphHost)/v1.0/me/memberOf?`$top=999" -ErrorAction Stop
    $myRoles   = @($myMemb.value | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' } |
                   ForEach-Object { $_.displayName })
    $matched   = $myRoles | Where-Object { $_ -in $RequiredDirectoryRoles }
    if (-not $matched) {
        throw @"
PRE-FLIGHT FAIL: caller $callerId is not assigned any of the directory roles
required for Control 1.8: $($RequiredDirectoryRoles -join ', ').
Caller currently holds: $(($myRoles -join ', '))
Without these roles, queries return zero rows that look identical to a clean
tenant — the worst possible false-clean pattern under SEC 17a-4(f) /
FINRA 4511. Have your Entra Privileged Role Administrator activate the
appropriate role(s) via PIM and re-run.
"@
    }

    # 6. License pre-flight --------------------------------------------------
    #    Managed Environments require the Power Platform Premium add-on. UAL
    #    retention >180 days requires Microsoft 365 E5 Compliance or the Audit
    #    Premium add-on. Per Get-MgSubscribedSku:
    #    https://learn.microsoft.com/en-us/graph/api/subscribedsku-list
    $skus = Get-MgSubscribedSku -ErrorAction Stop
    $hasPremium = [bool]($skus | Where-Object { $_.SkuPartNumber -match 'POWERAPPS_PER_USER|POWER_APPS_PREMIUM|PowerApps_Premium|POWERAPPS_PER_APP' })
    $hasAuditPremium = [bool]($skus | Where-Object { $_.SkuPartNumber -match 'M365_E5_COMPLIANCE|Microsoft_365_E5_Compliance|EQUIVIO_ANALYTICS|AUDIT_PREMIUM' })
    if (-not $hasPremium) {
        Write-Warning "PRE-FLIGHT WARN: tenant does not appear to carry a Power Platform Premium SKU. Managed Environments and Additional Threat Detection require Premium per https://learn.microsoft.com/en-us/power-platform/admin/managed-environment-overview. Inventory will still run; mutation scripts will surface 403 if Premium is genuinely absent."
    }
    if (-not $hasAuditPremium) {
        Write-Warning "PRE-FLIGHT WARN: tenant does not appear to carry Audit Premium / E5 Compliance. UAL retention is capped at 180 days per https://learn.microsoft.com/en-us/purview/audit-solutions-overview. Evidence runs spanning >180 days WILL return truncated results."
    }

    # 7. Microsoft 365 App Connector health (Defender for Cloud Apps) -------
    #    Read-only via Graph beta security/dataConnectors endpoint where
    #    available; fall back to a documented manual check.
    #    Per https://learn.microsoft.com/en-us/defender-cloud-apps/ai-agent-inventory
    #    AI Agents Inventory requires the M365 App Connector to be in Connected state.
    try {
        $connectors = Invoke-MgGraphRequest -Method GET `
            -Uri "$($endpoint.GraphHost)/beta/security/dataConnectors" -ErrorAction Stop
        $m365 = @($connectors.value | Where-Object { $_.displayName -match 'Microsoft 365|Office 365' })
        if (-not $m365 -or ($m365 | Where-Object { $_.status -ne 'connected' })) {
            Write-Warning "PRE-FLIGHT WARN: Microsoft 365 App Connector status is not 'connected' for all instances. Defender for Cloud Apps AI Agents Inventory will be empty or stale. Verify in the Defender XDR portal: Settings -> Cloud Apps -> Connected apps -> Microsoft 365."
        }
    } catch {
        Write-Warning "PRE-FLIGHT INFO: dataConnectors endpoint not available on $Cloud or caller lacks SecurityEvents.Read.All. Manually verify Microsoft 365 App Connector status at https://security.microsoft.com -> Settings -> Cloud Apps."
    }

    # 8. Emit session context -----------------------------------------------
    [PSCustomObject]@{
        Cloud             = $Cloud
        Endpoint          = $endpoint
        TenantId          = $mgCtx.TenantId
        Account           = $mgCtx.Account
        DirectoryRolesHeld= $myRoles
        DirectoryRolesMet = $matched
        HasPremium        = $hasPremium
        HasAuditPremium   = $hasAuditPremium
        InitializedUtc    = (Get-Date).ToUniversalTime().ToString('o')
    }
}

Why a separate Desktop entrypoint for PPAC? Microsoft.PowerApps.Administration.PowerShell ships only Windows PowerShell 5.1 binaries. Importing it into PowerShell 7 succeeds (the loader does not block it), but the cmdlets either return null or throw deserialization errors — producing false-clean evidence. The Initialize-Agt18Session helper above runs in PS 7.2+ for everything Graph and EXO can do; the Initialize-Agt18PpacSession helper in Section 3 runs in Windows PowerShell 5.1 for everything PPAC must do.


2. Coverage boundary — PowerShell vs portal vs Maker

This is the single source of truth for what belongs in PowerShell vs the Power Platform Admin Center, the Microsoft Defender XDR portal, and the Copilot Studio Maker for Control 1.8. Verify against Microsoft Learn before each change window — Microsoft has been moving capabilities between surfaces, and the beta Graph endpoints in particular evolve.

Capability PowerShell? Where it lives
Toggle Microsoft Defender — Copilot Studio AI Agents (the native DCA → Copilot Studio integration) No PPAC → Security → Threat protection → Microsoft Defender
Toggle Additional threat detection (the third-party webhook surface) on a per-environment basis No PPAC → Security → Threat protection → Additional threat detection
Paste the Entra App ID for the registered third-party webhook provider into the environment binding No — the App ID is typed by the operator into PPAC. PPAC then issues the FIC subject and issuer strings that the operator copies back into the Entra app registration. PPAC (paste App ID + copy subject / issuer)
Create the Entra app registration the webhook will run as YesNew-MgApplication Microsoft Graph
Create the federated identity credential on that app, using the operator-supplied subject and issuer from PPAC YesNew-MgApplicationFederatedIdentityCredential Microsoft Graph
Read AI agent inventory (Defender for Cloud Apps AI Agents) Yes — read-only. Microsoft Graph beta /beta/security/runHuntingQuery against the AIAppEvents schema. No typed cmdlet. Microsoft Graph beta
Read Defender XDR alerts for AI agent runtime threats Yes — read-only. Get-MgBetaSecurityAlert_v2 -Filter "serviceSource eq 'microsoftDefenderForCloudApps' and category eq 'InitialAccess'" (and similar). Microsoft Graph beta
Read Microsoft Sentinel logs for Power Platform admin activity Yes — read-only. Run-MgSecurityHuntingQuery against the PowerPlatformAdminActivity table. Critical: the activity-name column is EventOriginalType, not Operation. Microsoft Sentinel / Graph beta
Audit-log evidence stream for Copilot Studio runtime threat events (PromptInjectionDetected, ContentSafetyBlock, JailbreakAttemptDetected, ExternalThreatDetectionCallout) and CopilotInteraction user-prompt events YesSearch-UnifiedAuditLog (paged) over two RecordType families IPPS PowerShell
Per-agent Prompt Shield strength, content moderation level, jailbreak-detection toggle No Copilot Studio Maker → Settings → Generative AI → Content moderation
Environment-scoped DLP for connector egress (the Control-1.8 sliver of DLP) YesGet-DlpPolicy, New-DlpPolicy, Set-DlpPolicy Power Platform Admin (Desktop)
Conditional Access policies that constrain who can run AI agents (signal source for AI Agent Protection) Read via Graph; write via Graph or Entra portal. This is Control 1.2 territory; Control 1.8 only consumes the signal. Microsoft Graph / Entra portal
Rollback of a partial app + FIC creation YesRemove-MgApplicationFederatedIdentityCredential then Remove-MgApplication, with safety guards that confirm the app is the one created by the Configure script (via tag / displayName / app-creation-time-window). Microsoft Graph

Do not promote PowerShell as a substitute for portal-driven runtime-protection administration. If a sub-script in this playbook appears to "enable Defender integration" or "set the Additional Threat Detection provider", it is doing something else — most likely creating the app registration that the operator will then bind in PPAC, or reading the audit log for evidence that someone bound a provider in the portal. Document the boundary in every change ticket so reviewers do not expect a PS-based diff.

2.1 The webhook contract (what Power Platform sends; what your provider must answer)

Per Configure an external security provider:

  • Power Platform sends an HTTP POST to your provider URL on every Copilot Studio agent invocation, authenticated via a federated identity credential whose subject and issuer are issued by the Power Platform service and copied into the Entra app registration by the operator.
  • The request body carries the user prompt, agent identifier, environment identifier, and tenant identifier (verify the current schema against Learn — it has changed during the Prerelease window).
  • Your provider responds with allow or block and an optional reason string surfaced in the audit log as ExternalThreatDetectionCallout.
  • The PPAC binding includes an errorBehavior setting. Set errorBehavior = "Block" when the provider is part of a regulated control story — otherwise provider downtime causes Copilot Studio to fall back to allow, producing exactly the behavior the control was meant to prevent. Document the chosen behavior in your change ticket and reference it from the Control 1.8 exception register.
  • The webhook callout is synchronous — it sits in the agent invocation latency budget. Per Learn, a typical end-to-end target is sub-second, but specific SLA timing is variable; depends on provider region and tenant load — measure for your provider and record in your runtime SLO.

3. Inventory (read-only)

The PPAC inventory entrypoint runs in Windows PowerShell 5.1 (Desktop) because Microsoft.PowerApps.Administration.PowerShell requires it. Save as Get-Agt18Inventory.ps1 and run from powershell.exe, not pwsh.exe.

#Requires -Version 5.1
#Requires -PSEdition Desktop
#Requires -Modules @{ ModuleName = 'Microsoft.PowerApps.Administration.PowerShell'; RequiredVersion = '2.0.198' }

function Initialize-Agt18PpacSession {
    <#
    .SYNOPSIS
        Pre-flight for the PPAC half of Control 1.8 (Power Platform Admin
        cmdlets, which require Windows PowerShell 5.1).
    .DESCRIPTION
        Asserts edition, pins module version, resolves the sovereign
        -Endpoint switch, and opens an Add-PowerAppsAccount session. Read-only.
    #>
    [CmdletBinding()]
    param(
        [ValidateSet('Commercial','GCC','GCCHigh','DoD')]
        [string] $Cloud = 'Commercial'
    )

    $ErrorActionPreference = 'Stop'

    if ($PSVersionTable.PSEdition -ne 'Desktop') {
        throw "Microsoft.PowerApps.Administration.PowerShell requires Windows PowerShell 5.1 (Desktop). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion). Run from powershell.exe, not pwsh.exe."
    }

    $m = Get-Module -ListAvailable -Name Microsoft.PowerApps.Administration.PowerShell |
         Sort-Object Version -Descending | Select-Object -First 1
    if (-not $m) {
        throw "Microsoft.PowerApps.Administration.PowerShell is not installed. Install the CAB-pinned version: Install-Module Microsoft.PowerApps.Administration.PowerShell -RequiredVersion <pinned> -Scope CurrentUser."
    }
    Import-Module $m.Name -RequiredVersion $m.Version -Force

    $ppacEndpoint = switch ($Cloud) {
        'Commercial' { 'prod' }
        'GCC'        { 'usgov' }
        'GCCHigh'    { 'usgovhigh' }
        'DoD'        { 'dod' }
    }

    Add-PowerAppsAccount -Endpoint $ppacEndpoint | Out-Null
    [PSCustomObject]@{
        Cloud         = $Cloud
        PpacEndpoint  = $ppacEndpoint
        ModuleVersion = $m.Version.ToString()
        Edition       = $PSVersionTable.PSEdition
        InitializedUtc= (Get-Date).ToUniversalTime().ToString('o')
    }
}

3.1 Environment inventory with the correct managed-environments property path

The most common false-clean pattern in Control 1.8 inventory is reading the wrong property to detect Managed Environments. The current Microsoft.PowerApps.Administration.PowerShell schema exposes the managed-environment governance configuration under Properties.governanceConfigurationnot Properties.protectionLevel directly on the environment object. Reverify the property path against the pinned module version before each change window — Microsoft has refactored this object more than once.

# Session: Add-PowerAppsAccount (Desktop)
function Get-Agt18EnvironmentInventory {
    <#
    .SYNOPSIS
        Read-only inventory of every Power Platform environment with the
        Managed Environments and Additional Threat Detection signals needed
        for Control 1.8.
    .DESCRIPTION
        Reads governanceConfiguration.protectionLevel (NOT properties.protectionLevel),
        captures the Additional Threat Detection binding if present in the
        environment-properties bag, and emits JSON + CSV with SHA-256 sidecars.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $OutputDirectory
    )

    $ErrorActionPreference = 'Stop'
    New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
    $ts    = Get-Date -Format 'yyyyMMddTHHmmssZ'
    $runId = [guid]::NewGuid().Guid
    Start-Transcript -Path (Join-Path $OutputDirectory "transcript-env-inventory-$ts.log") -IncludeInvocationHeader

    try {
        $envs = Get-AdminPowerAppEnvironment

        $rows = foreach ($e in $envs) {
            $gov = $null
            if ($e.Internal.properties.PSObject.Properties.Name -contains 'governanceConfiguration') {
                $gov = $e.Internal.properties.governanceConfiguration
            }
            elseif ($e.Properties.PSObject.Properties.Name -contains 'governanceConfiguration') {
                $gov = $e.Properties.governanceConfiguration
            }

            $protectionLevel = $null
            if ($gov) { $protectionLevel = $gov.protectionLevel }

            # Additional Threat Detection binding (when present, surfaces under
            # security.threatProtection or threatProtection in the env properties
            # bag — verify the path against your pinned module version)
            $atd = $null
            foreach ($candidate in @('security.threatProtection','threatProtection','additionalThreatDetection')) {
                $val = $e.Internal.properties
                $hit = $true
                foreach ($seg in $candidate.Split('.')) {
                    if ($val -and $val.PSObject.Properties.Name -contains $seg) {
                        $val = $val.$seg
                    } else { $hit = $false; break }
                }
                if ($hit -and $val) { $atd = $val; break }
            }

            [PSCustomObject]@{
                DisplayName         = $e.DisplayName
                EnvironmentName     = $e.EnvironmentName
                EnvironmentType     = $e.EnvironmentType
                Location            = $e.Location
                IsManagedEnvironment= ($protectionLevel -in @('Standard','Basic','Custom'))
                ProtectionLevel     = $protectionLevel
                AdditionalThreatDetection = ($atd | ConvertTo-Json -Depth 6 -Compress)
                CreatedTime         = $e.CreatedTime
            }
        }

        $jsonPath = Join-Path $OutputDirectory "env-inventory-$ts.json"
        $csvPath  = Join-Path $OutputDirectory "env-inventory-$ts.csv"
        $rows | ConvertTo-Json -Depth 8 | Set-Content -Path $jsonPath -Encoding UTF8
        $rows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8

        $jsonHash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
        $csvHash  = (Get-FileHash -Path $csvPath  -Algorithm SHA256).Hash
        Set-Content -Path "$jsonPath.sha256" -Value $jsonHash -Encoding ASCII
        Set-Content -Path "$csvPath.sha256"  -Value $csvHash  -Encoding ASCII

        $manifest = [PSCustomObject]@{
            runId          = $runId
            control        = '1.8'
            artifact       = 'env-inventory'
            modulePin      = (Get-Module Microsoft.PowerApps.Administration.PowerShell).Version.ToString()
            ppacEndpoint   = (Get-PowerAppsAccount).Endpoint
            runner         = "$env:USERDOMAIN\$env:USERNAME"
            outputs        = @(
                @{ file = (Split-Path $jsonPath -Leaf); sha256 = $jsonHash; bytes = (Get-Item $jsonPath).Length }
                @{ file = (Split-Path $csvPath  -Leaf); sha256 = $csvHash;  bytes = (Get-Item $csvPath ).Length }
            )
            rowCount       = ($rows | Measure-Object).Count
            generatedUtc   = (Get-Date).ToUniversalTime().ToString('o')
        }
        $manifest | ConvertTo-Json -Depth 6 |
            Set-Content -Path (Join-Path $OutputDirectory "manifest-env-inventory-$ts.json") -Encoding UTF8

        $manifest
    }
    finally {
        Stop-Transcript | Out-Null
    }
}

3.2 DLP policy inventory (the Control-1.8 sliver of DLP)

Get-DlpPolicy returns every Power Platform DLP policy in the tenant. For Control 1.8 we care about the policies that constrain connector egress for the environments hosting Copilot Studio agents — the runtime guardrail that prevents an agent from being prompt-injected into reaching a non-business connector at request time.

# Session: Add-PowerAppsAccount (Desktop)
function Get-Agt18DlpInventory {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $OutputDirectory
    )

    $ErrorActionPreference = 'Stop'
    New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
    $ts    = Get-Date -Format 'yyyyMMddTHHmmssZ'
    $runId = [guid]::NewGuid().Guid
    Start-Transcript -Path (Join-Path $OutputDirectory "transcript-dlp-inventory-$ts.log") -IncludeInvocationHeader

    try {
        $policies = Get-DlpPolicy
        $rows = foreach ($p in $policies.value) {
            [PSCustomObject]@{
                Name              = $p.displayName
                PolicyName        = $p.name
                Type              = $p.environmentType
                CreatedBy         = $p.createdBy.userPrincipalName
                CreatedTime       = $p.createdTime
                LastModifiedTime  = $p.lastModifiedTime
                EnvironmentCount  = ($p.environments | Measure-Object).Count
                BusinessGroup     = ($p.connectorGroups | Where-Object { $_.classification -eq 'Confidential' }).connectors.Count
                NonBusinessGroup  = ($p.connectorGroups | Where-Object { $_.classification -eq 'General'      }).connectors.Count
                BlockedGroup      = ($p.connectorGroups | Where-Object { $_.classification -eq 'Blocked'      }).connectors.Count
            }
        }

        $jsonPath = Join-Path $OutputDirectory "dlp-inventory-$ts.json"
        $csvPath  = Join-Path $OutputDirectory "dlp-inventory-$ts.csv"
        $policies | ConvertTo-Json -Depth 12 | Set-Content -Path $jsonPath -Encoding UTF8
        $rows     | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8

        $jsonHash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
        $csvHash  = (Get-FileHash -Path $csvPath  -Algorithm SHA256).Hash
        Set-Content -Path "$jsonPath.sha256" -Value $jsonHash -Encoding ASCII
        Set-Content -Path "$csvPath.sha256"  -Value $csvHash  -Encoding ASCII

        [PSCustomObject]@{
            runId        = $runId
            control      = '1.8'
            artifact     = 'dlp-inventory'
            outputs      = @(
                @{ file = (Split-Path $jsonPath -Leaf); sha256 = $jsonHash; bytes = (Get-Item $jsonPath).Length }
                @{ file = (Split-Path $csvPath  -Leaf); sha256 = $csvHash;  bytes = (Get-Item $csvPath ).Length }
            )
            rowCount     = ($rows | Measure-Object).Count
            generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
        }
    }
    finally {
        Stop-Transcript | Out-Null
    }
}

3.3 Webhook FIC binding inventory

For each Entra app registration tagged as a Copilot Studio webhook provider (we recommend tag fsi:control:1.8:webhook-provider — set on creation in Section 4), enumerate the federated identity credentials and report on subject / issuer mismatch with the PPAC-issued values.

# Session: Connect-MgGraph (PS 7+)
function Get-Agt18WebhookFicInventory {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $OutputDirectory,
        [string] $Tag = 'fsi:control:1.8:webhook-provider'
    )

    $ErrorActionPreference = 'Stop'
    New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
    $ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
    Start-Transcript -Path (Join-Path $OutputDirectory "transcript-fic-inventory-$ts.log") -IncludeInvocationHeader

    try {
        $apps = Get-MgApplication -Filter "tags/any(t:t eq '$Tag')" -All -ErrorAction Stop
        $rows = foreach ($a in $apps) {
            $fics = Get-MgApplicationFederatedIdentityCredential -ApplicationId $a.Id -ErrorAction SilentlyContinue
            foreach ($fic in $fics) {
                [PSCustomObject]@{
                    AppDisplayName = $a.DisplayName
                    AppId          = $a.AppId
                    AppObjectId    = $a.Id
                    FicName        = $fic.Name
                    FicSubject     = $fic.Subject
                    FicIssuer      = $fic.Issuer
                    FicAudiences   = ($fic.Audiences -join ',')
                    Tag            = $Tag
                }
            }
        }

        $jsonPath = Join-Path $OutputDirectory "fic-inventory-$ts.json"
        $csvPath  = Join-Path $OutputDirectory "fic-inventory-$ts.csv"
        $rows | ConvertTo-Json -Depth 6 | Set-Content -Path $jsonPath -Encoding UTF8
        $rows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8

        $jsonHash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
        $csvHash  = (Get-FileHash -Path $csvPath  -Algorithm SHA256).Hash
        Set-Content -Path "$jsonPath.sha256" -Value $jsonHash -Encoding ASCII
        Set-Content -Path "$csvPath.sha256"  -Value $csvHash  -Encoding ASCII

        $rows
    }
    finally {
        Stop-Transcript | Out-Null
    }
}

Reconciliation rule. Every app tagged fsi:control:1.8:webhook-provider MUST have at least one FIC whose issuer matches the PPAC-issued issuer URL for the bound environment, and whose subject matches the PPAC-issued subject string. Any drift between the FIC subject / issuer and the values still displayed in PPAC means the binding is broken and the webhook will never be invoked — surface as [ALERT].


4. Mutation — app registration + federated identity credential

Operator workflow (READ FIRST). Per Configure an external security provider, the binding flow is:

  1. Run this script to create the Entra app registration. It emits the new AppId.
  2. Operator pastes the App ID into PPAC at Security → Threat protection → Additional threat detection. PPAC then displays the subject and issuer that the federated identity credential must use. Copy them from the PPAC UI. They are issued by the Power Platform service per environment + tenant + app and cannot be constructed client-side.
  3. Re-run this script with -Issuer and -Subject populated from PPAC. The script creates the FIC bound to those exact values.
  4. Operator returns to PPAC and confirms the binding now shows green / connected. The first agent invocation will exercise the webhook.

Any script that generates a subject value client-side — for example, by hashing the App ID or concatenating tenant + environment + app GUIDs — is fabricating credentials. It will produce a federated identity credential that PPAC will never trust. Earlier drafts of this playbook contained that fabrication. Do not.

Configure-AdditionalThreatDetection.ps1:

#Requires -Version 7.2
#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication';  RequiredVersion = '2.15.0' }
#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Applications';    RequiredVersion = '2.15.0' }

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory)] [string] $UserPrincipalName,
    [ValidateSet('Commercial','GCC','GCCHigh','DoD')]
    [string] $Cloud = 'Commercial',

    # App registration parameters -------------------------------------------
    [Parameter(Mandatory)] [string] $DisplayName,
    [Parameter(Mandatory)] [string] $OutputDirectory,

    # FIC parameters --- must be COPIED FROM PPAC AFTER FIRST RUN -----------
    # First run: omit -Issuer / -Subject. Script creates the app, emits the
    # AppId, and instructs the operator to paste it into PPAC.
    # Second run: pass -Issuer / -Subject as displayed by PPAC.
    [string] $Issuer,
    [string] $Subject,
    [string[]] $Audiences = @('api://AzureADTokenExchange'),
    [string] $FicName = 'PowerPlatform-AdditionalThreatDetection',

    # App identification on second run --------------------------------------
    [string] $AppObjectId,

    [string] $Tag = 'fsi:control:1.8:webhook-provider'
)

$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Initialize-Agt18Session.ps1')
$ctx = Initialize-Agt18Session -UserPrincipalName $UserPrincipalName -Cloud $Cloud

New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$ts    = Get-Date -Format 'yyyyMMddTHHmmssZ'
$runId = [guid]::NewGuid().Guid
$transcriptPath = Join-Path $OutputDirectory "transcript-configure-atd-$ts.log"
Start-Transcript -Path $transcriptPath -IncludeInvocationHeader

try {
    if (-not $AppObjectId) {
        # First run -----------------------------------------------------------
        if ($PSCmdlet.ShouldProcess($DisplayName, "Create new Entra app registration tagged '$Tag'")) {
            $app = New-MgApplication -DisplayName $DisplayName -Tags @($Tag) -SignInAudience 'AzureADMyOrg'
            Write-Host ""
            Write-Host "STEP 1 OF 2 COMPLETE — App registration created." -ForegroundColor Green
            Write-Host "  AppId       : $($app.AppId)"
            Write-Host "  ObjectId    : $($app.Id)"
            Write-Host "  DisplayName : $($app.DisplayName)"
            Write-Host ""
            Write-Host "NEXT STEPS (operator):" -ForegroundColor Yellow
            Write-Host "  1. Open PPAC -> environment -> Settings -> Security -> Threat protection ->"
            Write-Host "     Additional threat detection."
            Write-Host "  2. Paste this AppId: $($app.AppId)"
            Write-Host "  3. PPAC will display an Issuer URL and a Subject string."
            Write-Host "  4. Copy them and re-run this script with:"
            Write-Host "       -AppObjectId $($app.Id) -Issuer '<paste from PPAC>' -Subject '<paste from PPAC>'"
            Write-Host ""

            $result = [PSCustomObject]@{
                runId        = $runId
                control      = '1.8'
                step         = '1-of-2'
                appId        = $app.AppId
                appObjectId  = $app.Id
                displayName  = $app.DisplayName
                tag          = $Tag
                generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
            }
            $resultPath = Join-Path $OutputDirectory "configure-atd-step1-$ts.json"
            $result | ConvertTo-Json -Depth 4 | Set-Content -Path $resultPath -Encoding UTF8
            $resultHash = (Get-FileHash -Path $resultPath -Algorithm SHA256).Hash
            Set-Content -Path "$resultPath.sha256" -Value $resultHash -Encoding ASCII
        }
        return
    }

    # Second run --------------------------------------------------------------
    if (-not $Issuer)  { throw "Second run requires -Issuer (copied from PPAC after pasting AppId)." }
    if (-not $Subject) { throw "Second run requires -Subject (copied from PPAC after pasting AppId)." }

    $app = Get-MgApplication -ApplicationId $AppObjectId -ErrorAction Stop
    if ($Tag -notin @($app.Tags)) {
        throw "App $($app.AppId) is missing the safety tag '$Tag'. Refusing to mutate an app this script did not create. If this app was created out-of-band, add the tag manually after verifying provenance, or run with a fresh app."
    }

    # Idempotence: if a FIC with the same Name + Subject + Issuer already exists, skip.
    $existing = Get-MgApplicationFederatedIdentityCredential -ApplicationId $AppObjectId -ErrorAction SilentlyContinue |
                Where-Object { $_.Name -eq $FicName -and $_.Subject -eq $Subject -and $_.Issuer -eq $Issuer }
    if ($existing) {
        Write-Host "[NO-CHANGE] FIC '$FicName' already exists with matching Subject + Issuer." -ForegroundColor Cyan
    }
    elseif ($PSCmdlet.ShouldProcess("App $($app.AppId)", "Create federated identity credential '$FicName' with Subject='$Subject' Issuer='$Issuer'")) {
        $fic = New-MgApplicationFederatedIdentityCredential -ApplicationId $AppObjectId -BodyParameter @{
            name      = $FicName
            issuer    = $Issuer
            subject   = $Subject
            audiences = $Audiences
        }
        Write-Host "STEP 2 OF 2 COMPLETE — FIC created." -ForegroundColor Green
        Write-Host "  FIC Name : $($fic.Name)"
        Write-Host "  Issuer   : $($fic.Issuer)"
        Write-Host "  Subject  : $($fic.Subject)"
    }

    $result = [PSCustomObject]@{
        runId        = $runId
        control      = '1.8'
        step         = '2-of-2'
        appId        = $app.AppId
        appObjectId  = $app.Id
        ficName      = $FicName
        ficIssuer    = $Issuer
        ficSubject   = $Subject
        ficAudiences = $Audiences
        generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
    }
    $resultPath = Join-Path $OutputDirectory "configure-atd-step2-$ts.json"
    $result | ConvertTo-Json -Depth 4 | Set-Content -Path $resultPath -Encoding UTF8
    $resultHash = (Get-FileHash -Path $resultPath -Algorithm SHA256).Hash
    Set-Content -Path "$resultPath.sha256" -Value $resultHash -Encoding ASCII

    Write-Host ""
    Write-Host "REMAINING STEPS (operator):" -ForegroundColor Yellow
    Write-Host "  - Return to PPAC and confirm the Additional Threat Detection binding shows green."
    Write-Host "  - Set errorBehavior = 'Block' if this is a regulated control story."
    Write-Host "  - Run a canary prompt against a test agent and confirm one ExternalThreatDetectionCallout"
    Write-Host "    row appears in the unified audit log within 15 minutes (Section 5)."
}
finally {
    Stop-Transcript | Out-Null
}

Module pin assertion lives inside Initialize-Agt18Session. The transcript captures every Graph API URL, status code, and request body — store it under maintainers-local/tenant-evidence/ and reference it from your change ticket.


5. Evidence collection — audit log and runtime threat events

The Copilot Studio runtime threat surface produces audit events in two RecordType families:

  • CopilotStudio — the dedicated Copilot Studio audit family. Per Copilot Studio admin logging, this family carries operations such as PromptInjectionDetected, ContentSafetyBlock, JailbreakAttemptDetected, and ExternalThreatDetectionCallout. Reverify the operation list against Learn before each change window — the schema has expanded during the Prerelease window.
  • CopilotInteraction — the Microsoft 365 Copilot user-prompt audit family. Per Microsoft 365 Copilot auditing, this family carries the user-side prompt + response correlation that pairs with the Copilot Studio runtime threat events.

Always query both RecordTypes and reconcile in Section 7. Use an operation-name allow-list — never regex on English words like "prompt" or "jailbreak", which trips on user content and produces both false positives and false negatives.

Collect-RuntimeEvidence.ps1 — paged UAL collector with hard-fail at the documented 50 000-row session ceiling. Per Search-UnifiedAuditLog, -SessionCommand ReturnLargeSet returns rows in pages of up to 5 000 each; the same SessionId is capped at 50 000 rows total. Going past the ceiling without re-windowing produces silent truncation — the worst possible failure mode for an audit-log evidence script.

#Requires -Version 7.2
#Requires -Modules @{ ModuleName = 'ExchangeOnlineManagement'; RequiredVersion = '3.5.0' }

[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string] $UserPrincipalName,
    [ValidateSet('Commercial','GCC','GCCHigh','DoD')]
    [string] $Cloud = 'Commercial',
    [Parameter(Mandatory)] [datetime] $StartUtc,
    [Parameter(Mandatory)] [datetime] $EndUtc,
    [Parameter(Mandatory)] [string]   $OutputDirectory,
    [string[]] $RuntimeOperations = @(
        'PromptInjectionDetected',
        'ContentSafetyBlock',
        'JailbreakAttemptDetected',
        'ExternalThreatDetectionCallout'
    )
)

$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Initialize-Agt18Session.ps1')
$ctx = Initialize-Agt18Session -UserPrincipalName $UserPrincipalName -Cloud $Cloud

New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$ts    = Get-Date -Format 'yyyyMMddTHHmmssZ'
$runId = [guid]::NewGuid().Guid
Start-Transcript -Path (Join-Path $OutputDirectory "transcript-collect-$ts.log") -IncludeInvocationHeader

function Invoke-Agt18PagedSearch {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]   $RecordType,
        [Parameter(Mandatory)] [datetime] $StartUtc,
        [Parameter(Mandatory)] [datetime] $EndUtc,
        [string[]] $Operations
    )

    $sessionId   = [guid]::NewGuid().Guid
    $rows        = New-Object System.Collections.Generic.List[object]
    $pages       = 0
    $maxPages    = 10              # 10 pages * 5000 rows = 50,000-row session ceiling
    $resultSize  = 5000

    do {
        $pages++
        $page = Search-UnifiedAuditLog `
            -StartDate $StartUtc -EndDate $EndUtc `
            -RecordType $RecordType `
            -Operations $Operations `
            -SessionId $sessionId -SessionCommand ReturnLargeSet `
            -ResultSize $resultSize
        if ($page) { $rows.AddRange($page) }
    } while ($page -and $page.Count -eq $resultSize -and $pages -lt $maxPages)

    if ($pages -ge $maxPages -and $page.Count -eq $resultSize) {
        throw "PAGE-CEILING HIT: Search-UnifiedAuditLog SessionId $sessionId returned $($rows.Count) rows over $pages pages of $resultSize and is still saturated. The 50,000-row session ceiling is in effect; re-run with a narrower [-StartUtc, -EndUtc] window. DO NOT attempt to continue the same session — silent truncation is the documented failure mode."
    }

    [PSCustomObject]@{
        RecordType      = $RecordType
        Rows            = $rows
        Pages           = $pages
        SessionId       = $sessionId
        Saturated       = ($pages -ge $maxPages -and $page.Count -eq $resultSize)
    }
}

try {
    $copilotStudio       = Invoke-Agt18PagedSearch -RecordType 'CopilotStudio'      -StartUtc $StartUtc -EndUtc $EndUtc -Operations $RuntimeOperations
    $copilotInteraction  = Invoke-Agt18PagedSearch -RecordType 'CopilotInteraction' -StartUtc $StartUtc -EndUtc $EndUtc -Operations $RuntimeOperations

    function Save-Agt18AuditPage {
        param($Page, [string]$Stem)
        $jsonPath = Join-Path $OutputDirectory "$Stem-$ts.json"
        $csvPath  = Join-Path $OutputDirectory "$Stem-$ts.csv"
        $Page.Rows | ConvertTo-Json -Depth 8 | Set-Content -Path $jsonPath -Encoding UTF8
        $Page.Rows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
        $jh = (Get-FileHash $jsonPath -Algorithm SHA256).Hash
        $ch = (Get-FileHash $csvPath  -Algorithm SHA256).Hash
        Set-Content -Path "$jsonPath.sha256" -Value $jh -Encoding ASCII
        Set-Content -Path "$csvPath.sha256"  -Value $ch -Encoding ASCII
        @(
            @{ file = (Split-Path $jsonPath -Leaf); sha256 = $jh; bytes = (Get-Item $jsonPath).Length }
            @{ file = (Split-Path $csvPath  -Leaf); sha256 = $ch; bytes = (Get-Item $csvPath ).Length }
        )
    }

    $outputs  = @()
    $outputs += Save-Agt18AuditPage -Page $copilotStudio      -Stem 'ual-copilot-studio'
    $outputs += Save-Agt18AuditPage -Page $copilotInteraction -Stem 'ual-copilot-interaction'

    $manifest = [PSCustomObject]@{
        runId           = $runId
        control         = '1.8'
        artifact        = 'runtime-evidence'
        tenantId        = $ctx.TenantId
        cloud           = $ctx.Cloud
        runner          = $ctx.Account
        startUtc        = $StartUtc.ToUniversalTime().ToString('o')
        endUtc          = $EndUtc.ToUniversalTime().ToString('o')
        params          = @{
            recordTypes = @('CopilotStudio','CopilotInteraction')
            operations  = $RuntimeOperations
        }
        outputs         = $outputs
        rowCount        = ($copilotStudio.Rows.Count + $copilotInteraction.Rows.Count)
        pagesConsumed   = ($copilotStudio.Pages + $copilotInteraction.Pages)
        sessionsCopilotStudio      = $copilotStudio.SessionId
        sessionsCopilotInteraction = $copilotInteraction.SessionId
        generatedUtc    = (Get-Date).ToUniversalTime().ToString('o')
    }
    $manifest | ConvertTo-Json -Depth 6 |
        Set-Content -Path (Join-Path $OutputDirectory "manifest-runtime-evidence-$ts.json") -Encoding UTF8

    $manifest
}
finally {
    Stop-Transcript | Out-Null
}

Why a 50 000-row hard-fail. Per Search-UnifiedAuditLog, the documented session-row ceiling is 50 000. Past this point, additional pages return zero rows even when more records exist — there is no error, just silent truncation. Hard-failing at 10 pages of 5 000 forces the operator to narrow the time window, which keeps the evidence honest. Do not "retry" or "continue" past the ceiling within the same SessionId.


6. Evidence collection — Defender XDR alerts (read-only, Graph beta)

Defender for Cloud Apps surfaces AI agent runtime alerts in /beta/security/alerts_v2. Filter on serviceSource eq 'microsoftDefenderForCloudApps' and a category list relevant to AI agent threats. Verify the category and detection-source enumerations against the alerts_v2 schema on Learn before each change window — Microsoft has been adding categories during the AI Agent Protection Preview.

# Session: Connect-MgGraph (PS 7+)
function Get-Agt18DefenderAlerts {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [datetime] $StartUtc,
        [Parameter(Mandatory)] [datetime] $EndUtc,
        [Parameter(Mandatory)] [string]   $OutputDirectory,
        [string] $GraphHost = 'https://graph.microsoft.com'
    )

    $ErrorActionPreference = 'Stop'
    New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
    $ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
    Start-Transcript -Path (Join-Path $OutputDirectory "transcript-defender-$ts.log") -IncludeInvocationHeader

    try {
        $startIso = $StartUtc.ToUniversalTime().ToString('o')
        $endIso   = $EndUtc.ToUniversalTime().ToString('o')
        $filter   = "serviceSource eq 'microsoftDefenderForCloudApps' and createdDateTime ge $startIso and createdDateTime le $endIso"
        $uri      = "$GraphHost/beta/security/alerts_v2?`$filter=$([uri]::EscapeDataString($filter))&`$top=200"

        $rows = New-Object System.Collections.Generic.List[object]
        do {
            $resp = Invoke-MgGraphRequest -Method GET -Uri $uri -ErrorAction Stop
            if ($resp.value) { $rows.AddRange($resp.value) }
            $uri = $resp.'@odata.nextLink'
        } while ($uri)

        $jsonPath = Join-Path $OutputDirectory "defender-alerts-$ts.json"
        $rows | ConvertTo-Json -Depth 12 | Set-Content -Path $jsonPath -Encoding UTF8
        $hash = (Get-FileHash $jsonPath -Algorithm SHA256).Hash
        Set-Content -Path "$jsonPath.sha256" -Value $hash -Encoding ASCII

        [PSCustomObject]@{
            File         = $jsonPath
            Sha256       = $hash
            RowCount     = $rows.Count
            generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
        }
    }
    finally {
        Stop-Transcript | Out-Null
    }
}

OData-injection prevention. The $filter value is built from datetimes, not from caller-supplied strings, so this query is safe by construction. If you extend the script with a -AppId or -EnvironmentId parameter, validate it with [ValidatePattern('^[0-9a-fA-F-]{36}$')] on the parameter declaration before interpolating it into a $filter clause — concatenating an unvalidated string into an OData filter is the same attack class as SQL injection.


7. Reconciliation — UAL ↔ Defender alerts ↔ webhook callout outcomes

For every Copilot Studio agent invocation that triggers a runtime threat detection, you should see three correlated artifacts within roughly 15 minutes (specific propagation timing is variable; depends on tenant load and cloud — measure for your tenant and record in your Control 1.8 SLO):

  1. A CopilotStudio audit record with Operation in your runtime allow-list.
  2. (Where the Defender for Cloud Apps AI Agent Protection toggle is on) a Defender XDR alert with serviceSource = microsoftDefenderForCloudApps and an AI-agent-related category.
  3. (Where Additional Threat Detection is bound) an ExternalThreatDetectionCallout audit record with the bound provider's response — allow or block plus optional reason.

The reconciliation script joins these three streams on correlationId (Copilot Studio populates this on every invocation) and emits an exception report for any invocation that produced a runtime threat in one stream but not the others. Use it as the canary-loop verifier after every change window:

function Compare-Agt18Reconciliation {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $UalCopilotStudioJson,
        [Parameter(Mandatory)] [string] $DefenderAlertsJson,
        [Parameter(Mandatory)] [string] $OutputDirectory
    )

    $ErrorActionPreference = 'Stop'
    $ts = Get-Date -Format 'yyyyMMddTHHmmssZ'

    $ualRows = Get-Content $UalCopilotStudioJson -Raw | ConvertFrom-Json
    $alerts  = Get-Content $DefenderAlertsJson  -Raw | ConvertFrom-Json

    # Normalize correlation IDs --------------------------------------------
    $ualByCorr = @{}
    foreach ($r in $ualRows) {
        $audit = $r.AuditData | ConvertFrom-Json -ErrorAction SilentlyContinue
        $corr  = $audit.correlationId
        if ($corr) { $ualByCorr[$corr] = $r }
    }
    $alertCorrs = @{}
    foreach ($a in $alerts) {
        foreach ($e in @($a.evidence)) {
            if ($e.correlationId) { $alertCorrs[$e.correlationId] = $a }
        }
    }

    $orphanUal    = $ualByCorr.Keys | Where-Object { -not $alertCorrs.ContainsKey($_) }
    $orphanAlert  = $alertCorrs.Keys | Where-Object { -not $ualByCorr.ContainsKey($_) }

    $report = [PSCustomObject]@{
        ualCount             = $ualByCorr.Count
        defenderAlertCount   = $alertCorrs.Count
        ualRowsWithoutAlert  = @($orphanUal)
        alertsWithoutUalRow  = @($orphanAlert)
        generatedUtc         = (Get-Date).ToUniversalTime().ToString('o')
    }
    $reportPath = Join-Path $OutputDirectory "reconciliation-$ts.json"
    $report | ConvertTo-Json -Depth 6 | Set-Content -Path $reportPath -Encoding UTF8
    $reportHash = (Get-FileHash $reportPath -Algorithm SHA256).Hash
    Set-Content -Path "$reportPath.sha256" -Value $reportHash -Encoding ASCII

    $report
}

Surface results as [OK] / [WARN] / [ALERT] based on the reconciliation report, not as hardcoded [PASS]. [OK] means orphan counts are within tolerance for the tenant's known noise floor; [WARN] means orphans exceed tolerance but no high-severity Defender alerts are involved; [ALERT] means orphan Defender alerts of high severity exist (the most common indicator of a stale UAL ingestion or a missed Copilot Studio audit).


8. Microsoft Sentinel KQL — EventOriginalType, NOT Operation

If the tenant streams Power Platform admin activity into Microsoft Sentinel via the Microsoft Power Platform connector for Microsoft Sentinel, Control 1.8 evidence can also be queried via Sentinel KQL. Per the PowerPlatformAdminActivity table reference, the operation-name column is EventOriginalType — not Operation. Earlier drafts of this playbook used Operation and silently returned zero rows. Do not.

// All Copilot Studio runtime threat events surfaced in PowerPlatformAdminActivity
PowerPlatformAdminActivity
| where TimeGenerated >= ago(7d)
| where EventOriginalType in (
    "PromptInjectionDetected",
    "ContentSafetyBlock",
    "JailbreakAttemptDetected",
    "ExternalThreatDetectionCallout"
  )
| project TimeGenerated, EventOriginalType, EnvironmentName, AgentId = ResourceId,
          UserPrincipalName, ResultType, CorrelationId, AdditionalProperties
| order by TimeGenerated desc

Pair the table query with a Defender XDR alert join to surface high-severity, unreviewed AI agent alerts in the same window:

// Defender XDR AI agent alerts of high severity in the last 7 days
SecurityAlert
| where TimeGenerated >= ago(7d)
| where ProductName == "Microsoft Defender for Cloud Apps"
| where Severity == "High"
| where AlertName has_any ("AI agent", "Copilot agent", "Prompt injection", "Jailbreak")
| extend Correlation = tostring(parse_json(ExtendedProperties).CorrelationId)
| join kind=leftouter (
    PowerPlatformAdminActivity
    | where TimeGenerated >= ago(7d)
    | where EventOriginalType in (
        "PromptInjectionDetected",
        "ContentSafetyBlock",
        "JailbreakAttemptDetected",
        "ExternalThreatDetectionCallout"
      )
    | project Correlation = CorrelationId, EventOriginalType, EnvironmentName
  ) on Correlation
| project TimeGenerated, AlertName, Severity, EnvironmentName, EventOriginalType, Correlation, AlertLink
| order by TimeGenerated desc

The KQL queries also run through the Graph beta hunting endpoint when the tenant does not have Sentinel: Invoke-MgGraphRequest -Method POST -Uri "$($ctx.Endpoint.GraphHost)/beta/security/runHuntingQuery" -Body (@{ Query = $kql } | ConvertTo-Json). The schema is the same; the column-name correctness rule (EventOriginalType, not Operation) is identical.


9. Manifest pattern (recap)

Every script in this playbook emits a manifest-*.json next to its outputs. The manifest schema is the same across Controls 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.12 — re-use the parser in _shared/parse-manifest.ps1:

{
  "runId":          "9a2c1f30-8d11-4e0a-9c9c-7f2c6d9b8e21",
  "control":        "1.8",
  "artifact":       "runtime-evidence",
  "tenantId":       "00000000-0000-0000-0000-000000000000",
  "cloud":          "Commercial",
  "runner":         "admin@contoso.com",
  "startUtc":       "2026-02-01T00:00:00Z",
  "endUtc":         "2026-02-08T00:00:00Z",
  "params":         { "recordTypes": ["CopilotStudio","CopilotInteraction"], "operations": ["PromptInjectionDetected", "ContentSafetyBlock", "JailbreakAttemptDetected", "ExternalThreatDetectionCallout"] },
  "outputs":        [ { "file": "ual-copilot-studio-20260208T010203Z.json", "sha256": "…", "bytes": 12345 } ],
  "rowCount":       42,
  "pagesConsumed":  3,
  "generatedUtc":   "2026-02-08T01:02:03Z"
}

The outputs[*].sha256 is what your auditor will check against the sidecar .sha256 files when validating record integrity. Per SEC 17a-4(f), records held electronically must be retained in a non-rewriteable, non-erasable format — keep the manifests, the JSON / CSV outputs, the .sha256 sidecars, and the transcripts together in a write-once store (Microsoft Purview Records Management or your equivalent).


10. Rollback — Rollback-AdditionalThreatDetection.ps1

Removes the FIC and the app registration created by Configure-AdditionalThreatDetection.ps1. Never delete an app the script did not create — the -Tag and -CreatedAfter safety guards refuse to operate on apps with missing or mismatched provenance.

#Requires -Version 7.2
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory)] [string] $UserPrincipalName,
    [ValidateSet('Commercial','GCC','GCCHigh','DoD')] [string] $Cloud = 'Commercial',
    [Parameter(Mandatory)] [string] $AppObjectId,
    [Parameter(Mandatory)] [string] $OutputDirectory,
    [string] $RequiredTag = 'fsi:control:1.8:webhook-provider'
)

$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Initialize-Agt18Session.ps1')
$ctx = Initialize-Agt18Session -UserPrincipalName $UserPrincipalName -Cloud $Cloud
$ts  = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path (Join-Path $OutputDirectory "transcript-rollback-$ts.log") -IncludeInvocationHeader

try {
    $app = Get-MgApplication -ApplicationId $AppObjectId -ErrorAction Stop
    if ($RequiredTag -notin @($app.Tags)) {
        throw "REFUSING TO DELETE: App $($app.AppId) is missing the required safety tag '$RequiredTag'. This script only removes apps it (or a sibling Configure-AdditionalThreatDetection.ps1 run) created. Apps created out-of-band must be removed via a tracked change ticket and a separate, explicit cmdlet — not this script."
    }

    Write-Warning @"
About to remove federated identity credentials AND the app registration for:
  AppId       : $($app.AppId)
  ObjectId    : $($app.Id)
  DisplayName : $($app.DisplayName)
This will BREAK any PPAC Additional Threat Detection binding still pointing at
this AppId. CONFIRM the binding has been removed in PPAC FIRST, otherwise the
next Copilot Studio agent invocation will fail closed (if errorBehavior=Block)
or fall through with no provider check (if errorBehavior=Allow).
"@

    $fics = Get-MgApplicationFederatedIdentityCredential -ApplicationId $AppObjectId -ErrorAction SilentlyContinue
    foreach ($fic in $fics) {
        if ($PSCmdlet.ShouldProcess("App $($app.AppId)", "Remove FIC '$($fic.Name)'")) {
            Remove-MgApplicationFederatedIdentityCredential -ApplicationId $AppObjectId -FederatedIdentityCredentialId $fic.Id -ErrorAction Stop
        }
    }

    if ($PSCmdlet.ShouldProcess("App $($app.AppId)", "Remove app registration")) {
        Remove-MgApplication -ApplicationId $AppObjectId -ErrorAction Stop
    }

    [PSCustomObject]@{
        runId        = [guid]::NewGuid().Guid
        control      = '1.8'
        artifact     = 'rollback'
        appId        = $app.AppId
        appObjectId  = $app.Id
        ficsRemoved  = ($fics | Measure-Object).Count
        generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
    } | ConvertTo-Json -Depth 4 |
        Set-Content -Path (Join-Path $OutputDirectory "rollback-$ts.json") -Encoding UTF8
}
finally {
    Stop-Transcript | Out-Null
}

10a. Disconnect

Always disconnect at the end of every session. Do not wrap with -ErrorAction SilentlyContinue — a failed disconnect is a signal worth seeing (it usually means a parallel session is still running, which can produce inconsistent evidence).

Disconnect-MgGraph
Disconnect-ExchangeOnline -Confirm:$false
# Power Platform (Desktop session): no documented Remove-PowerAppsAccount; close the host process.

11. Sovereign cloud reference

Cloud Connect-MgGraph -Environment Graph host IPPS ConnectionUri AAD authority PPAC -Endpoint Notes
Commercial Global https://graph.microsoft.com https://ps.compliance.protection.outlook.com/powershell-liveid/ https://login.microsoftonline.com/organizations prod Defender AI Agent Protection in Preview; Additional Threat Detection in Prerelease — verify per change window.
GCC Global https://graph.microsoft.com https://ps.compliance.protection.outlook.com/powershell-liveid/ https://login.microsoftonline.com/organizations usgov Same Graph endpoints as Commercial. Defender AI Agent Protection and Additional Threat Detection availability NOT documented at parity — apply compensating controls.
GCC High USGov https://graph.microsoft.us https://ps.compliance.protection.office365.us/powershell-liveid/ https://login.microsoftonline.us/organizations usgovhigh Distinct Graph + IPPS hosts. Verify Copilot Studio + Defender for Cloud Apps availability against requirements-licensing-gcc before each change window.
DoD (L5) USGovDoD https://dod-graph.microsoft.us https://l5.ps.compliance.protection.office365.us/powershell-liveid/ https://login.microsoftonline.us/organizations dod Dedicated L5 IPPS host. Generative-AI dependencies have separate availability constraints — reverify per change window.

Sources:

ZERO ROWS IN A GOV CLOUD DOES NOT MEAN A CLEAN TENANT. Several Control 1.8 surfaces are not available in GCC / GCC High / DoD as of February 2026. A successful run that returns no Defender alerts and no CopilotStudio audit rows in those clouds means the surfaces are not active, not the tenant is clean. Document the gap and apply compensating controls.


12. Anti-patterns — what NOT to do

Every item in this list maps to a real failure mode discovered during Control 1.8 review.

  1. Constructing FIC subject or issuer client-side. PPAC issues these per environment + tenant + app. Any script that hashes the App ID, concatenates GUIDs, or generates a "predictable" subject is fabricating credentials — PPAC will never trust them. Always pass -Issuer and -Subject from the PPAC UI on the second run of Configure-AdditionalThreatDetection.ps1.
  2. Search-UnifiedAuditLog without -SessionId + -SessionCommand ReturnLargeSet. A single-shot call returns at most 5 000 rows and silently truncates beyond that. The paged pattern in Section 5 is non-negotiable for evidence runs.
  3. Continuing past the 50 000-row session ceiling. Per Learn, the same SessionId is capped at 50 000 rows. Past that point, additional calls return zero rows with no error. Hard-fail at 10 pages of 5 000 and force the operator to narrow the time window.
  4. Querying only RecordType = CopilotInteraction. That family carries Microsoft 365 Copilot user-prompt events; it does not carry Copilot Studio runtime threat operations. Always query CopilotStudio and CopilotInteraction and reconcile.
  5. Regexing on English words like prompt or jailbreak instead of an operation-name allow-list. User content containing those words trips false positives; legitimate detections under operation names you did not enumerate are missed entirely. Use the explicit -Operations allow-list and reverify it against Learn before each change window.
  6. Using the wrong KQL column. PowerPlatformAdminActivity exposes activity names under EventOriginalType, not Operation. The query silently returns zero rows when the column is wrong — there is no schema error.
  7. Hard-coded [PASS] / [FAIL] banners. Verification scripts that always print [PASS] regardless of state are evidence theatre. Surface [OK] / [WARN] / [ALERT] based on the reconciliation report and the orphan-alert tolerance documented in your Control 1.8 SLO.
  8. Reading $env.Properties.protectionLevel to detect Managed Environments. The current schema exposes the value at $env.Internal.properties.governanceConfiguration.protectionLevel (or $env.Properties.governanceConfiguration.protectionLevel depending on module version). The wrong path returns $null, which the script then false-clean reports as "not managed".
  9. Mutation cmdlets without [CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]. No -WhatIf or -Confirm support means the change-management trail is missing. CAB will reject.
  10. Removing app registrations without a provenance tag check. Remove-MgApplication is irreversible. The rollback script must refuse to operate on apps without the fsi:control:1.8:webhook-provider tag (or whatever tag the Configure script set).
  11. Wrapping Connect-* or Disconnect-* with -ErrorAction SilentlyContinue. Both signals matter — a failed connect is a sovereign-endpoint or scope problem; a failed disconnect is a parallel-session problem. Suppressing them produces silent drift.
  12. Skipping the role pre-flight. Without an Application Administrator (or higher) role, New-MgApplication returns 403 — but a Graph session opened with delegated Application.ReadWrite.OwnedBy will sometimes accept the call and create an app the operator cannot manage afterwards. Always assert role membership in Initialize-Agt18Session.
  13. Skipping the Microsoft 365 App Connector health check. Defender for Cloud Apps AI Agents Inventory will be empty or stale if the connector is degraded. Empty inventory looks identical to "no agents in tenant" — false-clean.
  14. Treating commercial-cloud Learn URLs as authoritative for GCC / GCC High / DoD. Several Control 1.8 surfaces have no documented gov-cloud parity. Reverify per cloud against the GCC requirements page and, where the surface is unavailable, document the gap and apply compensating controls.
  15. Skipping transcript capture. Start-Transcript + -IncludeInvocationHeader is what your auditor will read when reconstructing what happened in the session. It is not optional for mutation scripts.

Related control Why it matters for Control 1.8
1.4 — Advanced Connector Policies (ACP) Connector-level controls that constrain which connectors a Copilot Studio agent can invoke at runtime.
1.5 — Data Loss Prevention (DLP) and Sensitivity Labels Environment-scoped DLP referenced in Section 3.2; sensitivity labels feed AI Agent Protection alert evidence.
1.6 — Microsoft Purview DSPM for AI DSPM for AI surfaces the data-classification posture that AI Agent Protection alerts cite in their evidence.
1.7 — Comprehensive Audit Logging and Compliance UAL retention beyond 180 days requires the Audit Premium SKU asserted in the Section 1 pre-flight.
1.9 — Data Retention and Deletion Policies Defines how long Copilot Studio audit, transcript, and Defender alert evidence is retained — pairs with SEC 17a-4(f) record-keeping requirements.
1.10 — Communication Compliance Monitoring The companion Comms Compliance review surface for human-language threat patterns — pairs with the Control 1.8 runtime threat events.
1.13 — Sensitive Information Types (SITs) and Pattern Recognition SIT detections are evidence inputs to AI Agent Protection alerts and Communication Compliance reviews.
2.7 — Vendor and Third-Party Risk Management Third-party security webhook providers bound via Additional Threat Detection are vendors subject to this control's risk-management requirements.

Updated: February 2026 | Version: v1.4.0