Skip to content

Control 3.1 — PowerShell Setup: Agent Inventory and Metadata Management Automation

Scope. This playbook automates the multi-plane discovery, reconciliation, enrichment, and evidence emission required by Control 3.1 across Copilot Studio bots, declarative agents, Microsoft 365 Copilot extensibility, MCP servers, the emerging Agent Registry / Agent 365 surface, SharePoint Connected agents, and Azure AI Foundry agent resources in US financial services tenants. It assumes you have already read ../../_shared/powershell-baseline.md (referenced below as BL-§N) and the parent control specification ../../../controls/pillar-3-reporting/3.1-agent-inventory-and-metadata-management.md.

What this playbook is. A reproducible, fail-closed reconciliation harness that (a) pins module / CLI versions; (b) bootstraps a sovereign-aware, certificate-authenticated, audit-only session split across the PowerShell 5.1 Desktop and PowerShell 7.4 Core editions; (c) collects inventory from each Microsoft data plane that exposes agents today; (d) merges those streams into one canonical schema with an immutable CanonicalAgentId; (e) enriches the merged register with owner, last-activity, DLP, sensitivity-label, and manager metadata; (f) flags departed-owner, dormant, and unowned agents; (g) drives a documented lifecycle state machine; and (h) emits an examiner-ready evidence pack with SHA-256 hashes and a certificate-signed manifest.

What this playbook is not. It does not replace the authoritative system of record (SharePoint list, Dataverse table, CMDB, or GRC tool). It does not, by itself, guarantee that every agent in your tenant is captured — Microsoft's discovery surfaces have known gaps (documented in §0 and §6) that organizations should compensate for through policy, manual attestation, and Defender for Cloud Apps shadow-IT detection (Control 3.6). It does not approve, decommission, or transfer agents; it raises evidence and inventory state, and humans accept risk.

Hedged language reminder. Output of this harness supports compliance with FINRA Rule 4511, FINRA Regulatory Notice 25-07, SEC 17a-4(b)(4), SOX 302/404, GLBA 501(b), NYDFS Part 500.16/500.17, OCC Bulletin 2011-12 / Fed SR 11-7, and NIST AI RMF GOVERN 1.6 books-and-records expectations. It does not, by itself, ensure a passing examination, guarantee completeness against an unknowable shadow population, or eliminate the risk that a discovery surface drifts between releases. Implementation requires that organizations verify endpoint availability, module pinning, and sovereign feature parity at every change window.

Field Value
Control ID 3.1
Pillar 3 — Reporting (FOUNDATION)
Playbook PowerShell Setup
PowerShell Edition 7.4 LTS Core (orchestrator); 5.1 Desktop (Power Apps Administration sub-shell, spawned and JSON-bridged)
Sovereign Clouds Commercial, GCC, GCC High, DoD, China (21Vianet) — see §1 sovereign matrix and §2 bootstrap
Last UI Verified April 2026
Companion Playbooks portal-walkthrough.md · verification-testing.md · troubleshooting.md
Related Controls 1.7 · 1.10 · 1.19 · 2.1 · 2.5 · 3.6 · 3.8 · 3.11 · Incident & Risk Playbook

§0 — Wrong-shell trap and the multi-plane false-clean defect (READ FIRST)

The defining fact of Control 3.1. As of April 2026, no single Microsoft API returns "all agents" in a tenant. A script that connects to one plane — even the most comprehensive one — and reports a number is producing audit-grade misinformation. The Power Platform admin surface knows about Copilot Studio bots and Power Apps that act as agent hosts; Microsoft Graph knows about declarative agents registered as Entra applications and about MCP server service principals; the M365 Copilot Hub telemetry knows about agents users are actually invoking (including ones never registered in your tenant catalog); SharePoint admin knows about grounding-source connected agents; the emerging Agent Registry / Agent 365 surface is partially GA in some clouds and unavailable in others. Reconciliation across these planes is the engineering problem this playbook solves.

A script that ignores this reality produces a false-clean inventory — the worst possible Control 3.1 outcome. False-clean inventory understates books-and-records exposure under FINRA Rule 4511 / SEC 17a-4(b)(4), produces the wrong denominator for every downstream metric in Pillar 3, and breaks the audit trail that supervisory examination response depends on (see Control 3.8 for downstream dashboards).

Why this section exists. Three classes of silent failure produce false-clean inventory in Control 3.1 specifically:

  1. Wrong PowerShell edition for the plane being queried. Microsoft.PowerApps.Administration.PowerShell and Microsoft.PowerApps.PowerShell are Desktop-only (Windows PowerShell 5.1) — they autoload in PowerShell 7 but several cmdlets silently return empty arrays instead of throwing. Microsoft.Graph.Beta, ExchangeOnlineManagement v3+, modern PnP.PowerShell v2+, and Az.Accounts v3+ are Core-only (PowerShell 7.2+). A reconciler that runs end-to-end in one edition is necessarily incomplete on at least one plane.
  2. Sovereign mis-routing. Add-PowerAppsAccount without an explicit -Endpoint usgov / usgovhigh / dod parameter authenticates against commercial endpoints, returns zero environments in a GCC / GCC High / DoD tenant, and produces zero-row CSVs that look identical to a clean inventory. The same defect applies to Connect-MgGraph -Environment and Connect-PnPOnline -Url. See BL-§3.
  3. Beta vs v1.0 Microsoft Graph drift. Several Copilot / agent endpoints (/copilot/admin/settings, /applications agent-tag filters, /reports/getCopilotAIInteractionsCount, /sites/{id}/copilotAgents, /agents registry preview) move between beta and v1.0 quarterly, with breaking shape changes between minor SDK releases. Pinning is mandatory and version-stamping every call into the manifest is required for examiner reproducibility.

Top false-clean defects unique to inventory automation.

# Defect What it looks like How this playbook traps it
1 Get-AdminPowerApp on Core silently returns $null for Owner Owner column populates as blank for every row §0 edition assertion + §3 child-process spawn pattern
2 appType for Copilot Studio bots renamed across module versions (BotChatBotCopilotStudioBot) Filter loses agents on the older value §3 enumerates all appType values seen in tenant and warns on any unknown value
3 Add-PowerAppsAccount defaulted to prod in a GCC tenant Returns zero environments; CSV is empty but exit code is 0 §2 hard-fail when sovereign discriminator is detected on tenant but -Endpoint mismatches
4 Connect-MgGraph -Scopes requested but admin consent never granted Get-MgApplication returns 200 with empty value array §2 verifies granted scopes via (Get-MgContext).Scopes and exits 2 on mismatch
5 MCP servers filtered as Get-MgApplication (they are service principals) Entire MCP population missing from inventory §7 queries Get-MgServicePrincipal with both tag-based and known-appId filters
6 SharePoint Agents enumerated only through Power Platform SPO-grounded agents missing entirely §8 walks SPO admin + Graph /sites/{id}/copilotAgents (preview)
7 Search-UnifiedAuditLog returning fewer than 5,000 rows treated as authoritative Pagination cut off; "dormant" classification falsely applied §10 paginates with SessionId + SessionCommand ReturnLargeSet per Microsoft guidance
8 Owner string-match for orphan detection (*system*, *deleted*) Real orphans missed; false positives spike §11 resolves Owner ObjectId against Get-MgUser and accountEnabled
9 CSV output overwritten on each run No historical reconciliation; deltas invisible §13 emits run-id-stamped artifacts to a WORM store
10 SHA-256 computed but never signed Hash file can be regenerated; no cryptographic provenance §13 signs manifest.json with the Inventory Owner code-signing certificate

Required shell guard (run this at the top of every Control 3.1 session).

# Save as: scripts/Assert-Agt31Shell.ps1
[CmdletBinding()]
[OutputType([void])]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion -lt [version]'7.4.0') {
    Write-Error "Control 3.1 orchestrator requires PowerShell 7.4 LTS Core (pwsh). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion). Launch 'pwsh' (not 'powershell.exe') and retry. The Power Apps Administration leg will be spawned into a Windows PowerShell 5.1 child process by §3."
    exit 2
}

# Trap stale Windows PowerShell module shadowing (silent autoload of v1 Microsoft.Graph or v1 PnP.PowerShell)
$desktopPaths = $env:PSModulePath -split [IO.Path]::PathSeparator | Where-Object { $_ -match 'WindowsPowerShell\\Modules' }
if ($desktopPaths) {
    $bad = Get-Module -ListAvailable -Name 'Microsoft.Graph','PnP.PowerShell' |
        Where-Object { $_.Version.Major -lt 2 }
    if ($bad) {
        Write-Error "Stale v1 modules visible from Desktop module path: $($bad | ForEach-Object { "$($_.Name)=$($_.Version)" } | Sort-Object -Unique). Uninstall before continuing — autoload would silently shadow the pinned v2 modules and produce wrong-shape inventory output."
        exit 2
    }
}

Fail-closed conditions enforced by this guard:

  • Detected PowerShell edition is Desktop or version < 7.4.0exit 2.
  • v1 of Microsoft.Graph or PnP.PowerShell discoverable on $env:PSModulePathexit 2.
  • Any pinned module missing the exact RequiredVersion after install attempt (verified in §1) → exit 2.

§1 — Module, CLI, package, and permission matrix

Why this section exists. Inventory output is reproducible only when versions are declared, hashed, and emitted into the manifest. A pinned baseline also lets the change-management ticket reference exact module versions, which is what SOX 404 evidence reviewers expect.

1.1 Pinned PowerShell modules (orchestrator — PS 7.4 Core)

# Save as: scripts/Install-Agt31Modules.ps1
[CmdletBinding(SupportsShouldProcess)]
[OutputType([void])]
param(
    [Parameter()] [switch]$AcceptLicense
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$modules = @(
    @{ Name = 'Microsoft.Graph.Authentication';                   Version = '2.25.0' },
    @{ Name = 'Microsoft.Graph.Applications';                     Version = '2.25.0' },
    @{ Name = 'Microsoft.Graph.Reports';                          Version = '2.25.0' },
    @{ Name = 'Microsoft.Graph.Users';                            Version = '2.25.0' },
    @{ Name = 'Microsoft.Graph.Identity.DirectoryManagement';     Version = '2.25.0' },
    @{ Name = 'Microsoft.Graph.Beta.Applications';                Version = '2.25.0' },
    @{ Name = 'Microsoft.Graph.Beta.Reports';                     Version = '2.25.0' },
    @{ Name = 'Microsoft.Graph.Beta.Sites';                       Version = '2.25.0' },
    @{ Name = 'ExchangeOnlineManagement';                         Version = '3.7.0'  },
    @{ Name = 'PnP.PowerShell';                                   Version = '2.12.0' },
    @{ Name = 'Microsoft.Online.SharePoint.PowerShell';           Version = '16.0.25515.12000' },
    @{ Name = 'Az.Accounts';                                      Version = '3.0.0'  },
    @{ Name = 'Az.CognitiveServices';                             Version = '1.14.0' },
    @{ Name = 'Az.Resources';                                     Version = '7.4.0'  }
)

foreach ($m in $modules) {
    $existing = Get-Module -ListAvailable -Name $m.Name |
        Where-Object { $_.Version -eq [version]$m.Version }
    if (-not $existing) {
        if ($PSCmdlet.ShouldProcess("$($m.Name)@$($m.Version)", 'Install-Module')) {
            Install-Module -Name $m.Name -RequiredVersion $m.Version `
                -Scope CurrentUser -Repository PSGallery -AllowClobber `
                -AcceptLicense:$AcceptLicense -ErrorAction Stop
        }
    }
    Import-Module -Name $m.Name -RequiredVersion $m.Version -Force -ErrorAction Stop
    Write-Verbose "Imported $($m.Name) $($m.Version)"
}

# Desktop-only Power Apps modules: install into 5.1 sub-shell, NEVER into pwsh 7
$ppAdminVersion   = '2.0.183'
$ppMakerVersion   = '1.0.34'
if ($IsWindows) {
    if ($PSCmdlet.ShouldProcess("Microsoft.PowerApps.Administration.PowerShell@$ppAdminVersion (Desktop 5.1)", 'Install-Module via powershell.exe')) {
        powershell.exe -NoProfile -Command "Install-Module -Name Microsoft.PowerApps.Administration.PowerShell -RequiredVersion $ppAdminVersion -Scope CurrentUser -Force -AllowClobber" | Out-Null
        powershell.exe -NoProfile -Command "Install-Module -Name Microsoft.PowerApps.PowerShell -RequiredVersion $ppMakerVersion -Scope CurrentUser -Force -AllowClobber" | Out-Null
    }
} else {
    Write-Warning "Non-Windows host detected. Power Apps Administration cmdlets are Windows-only. The reconciler will skip the Power Platform leg unless a Windows worker is available (see §3)."
}

<#
.SYNOPSIS
    Pins every PowerShell module Control 3.1 depends on to a CAB-approved version.
.DESCRIPTION
    Idempotent installer. Skips any module already present at the exact RequiredVersion.
    Spawns a Windows PowerShell 5.1 child process for the Power Apps Administration modules
    because they are Desktop-only and silently misbehave under PowerShell 7.
.EXAMPLE
    PS> ./Install-Agt31Modules.ps1 -WhatIf
    Lists every install action that would be taken without performing it.
.EXAMPLE
    PS> ./Install-Agt31Modules.ps1 -AcceptLicense -Verbose
    Performs the install for unattended scheduler use.
.NOTES
    Verify pinned versions against your CAB-approved baseline before each run.
#>

1.2 Pinned CLI tooling

Tool Minimum Used for Sovereign notes
Power Platform CLI (pac) 1.45.0 pac copilot list, pac admin list-app-resources, pac auth create --cloud Pass --cloud {Public\|UsGov\|UsGovHigh\|DoD}
Microsoft 365 CLI (m365) 7.0.0 Optional declarative-agent enumeration cross-check Public + GCC verified; GCC High limited
Azure CLI (az) 2.60.0 AI Foundry project lookup, role-assignment evidence az cloud set --name {AzureCloud\|AzureUSGovernment\|AzureChinaCloud}
Python 3.11.0 Microsoft Graph beta wrappers for endpoints not yet in PowerShell SDK (e.g., preview /agents) OSS, portable across all clouds
Get-FileHash (built-in) n/a SHA-256 evidence hashes n/a
signtool.exe or Set-AuthenticodeSignature n/a Manifest signing in §13 Code-signing cert must chain to CA approved by Information Security

Pin check (must run before §2 bootstrap):

# scripts/Test-Agt31Tooling.ps1
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$results = @()
$pacVer = (& pac --version 2>$null) -join ' '
$results += [pscustomobject]@{ Tool='pac'; Found=$pacVer; Required='>=1.45.0'; Pass=($pacVer -match '1\.(4[5-9]|[5-9]\d)') }

$azVer  = (& az version --output json 2>$null) | ConvertFrom-Json
$results += [pscustomobject]@{ Tool='az'; Found=$azVer.'azure-cli'; Required='>=2.60.0'; Pass=([version]$azVer.'azure-cli' -ge [version]'2.60.0') }

$pyVer  = (& python --version 2>$null) -replace 'Python ',''
$results += [pscustomobject]@{ Tool='python'; Found=$pyVer; Required='>=3.11.0'; Pass=([version]$pyVer -ge [version]'3.11.0') }

$results
if ($results.Where({ -not $_.Pass }).Count) {
    Write-Error "One or more tooling pin checks failed. Review the results table above."
    exit 2
}

1.3 Pinned Python packages (Graph beta wrappers)

requirements.agt31.txt:

msgraph-sdk>=1.5.0,<2.0.0
msgraph-beta-sdk>=1.5.0,<2.0.0
azure-identity>=1.17.0,<2.0.0
azure-mgmt-cognitiveservices>=13.5.0,<14.0.0
pandas>=2.2.0,<3.0.0
openpyxl>=3.1.2
cryptography>=42.0.0

The Python leg is invoked from §6 (Agent Registry preview) and §5 (Copilot AI interaction count where the PowerShell SDK lags behind beta). Python is not the primary execution surface; it is a thin wrapper around endpoints not yet exposed in Microsoft.Graph.Beta.

1.4 Permission matrix (audit-only, separate inventory-reader credentials)

Inventory must run unattended on a schedule. The Inventory Reader service principal should be distinct from any service principal that changes tenant state. This separation supports SOX 404 separation-of-duties: the principal that produces the evidence is not the principal that could alter the configuration the evidence describes.

Principal / Role Granted on Permission Why this script needs it Audit-only?
agt31-inventory-reader (SP) Microsoft Graph Application.Read.All (Application) Enumerate Entra app registrations including declarative agents; required for §4 Yes
same SP Microsoft Graph AgentApplication.Read.All (Application, where available) Agent 365 / Agent Registry preview; §6 Yes
same SP Microsoft Graph CopilotSettings-LimitedMode.Read.All (or current GA equivalent) /copilot/admin settings; §5 Yes
same SP Microsoft Graph Reports.Read.All (Application) getCopilotAIInteractionsCount aggregation; §5 Yes
same SP Microsoft Graph AuditLog.Read.All (Application) Sign-in / activity for Last Activity field; §10 Yes
same SP Microsoft Graph User.Read.All (Application) Resolve Owner ObjectId → Entra user; orphan detection in §11 Yes
same SP Microsoft Graph InformationProtectionPolicy.Read.All (Application) Sensitivity label resolution; §10 Yes
same SP Microsoft Graph Sites.Read.All (Application) SharePoint Connected Agents via Graph; §8 Yes
agt31-pp-reader (SP) Power Platform Power Platform Service Admin (read-only intent only) Get-AdminPowerApp, Get-AdminFlow, Get-AdminPowerAppEnvironment; §3 Yes (intent)
agt31-spo-reader (SP) SharePoint Online SharePoint Administrator Get-SPOSite, agent-enabled site flags; §8 Yes (intent)
agt31-purview-reader (SP) Microsoft Purview View-Only Audit Logs, Compliance Administrator (read intent) Search-UnifiedAuditLog activity enrichment; §10 Yes (intent)
Operator (human) Entra PIM Global Reader time-bound Manual reconciliation review; never used for the unattended pipeline Yes

Hedging note on permission scope. The inventory reader is scoped to read-only application permissions; tenant administrators should still review the consent grants quarterly under Control 1.1 — Service Principal Governance and rotate the certificate at the cadence defined by your PKI policy. "Read-only" is a contract with Microsoft Graph, not a guarantee that the credential cannot be used for reconnaissance — treat the inventory reader as a privileged identity for monitoring and detection purposes.

Sovereign cloud parity matrix (verify at deploy time per the parent control).

Plane Commercial GCC GCC High DoD
Power Platform Admin (Get-AdminPowerApp, pac copilot list) GA Rolling — verify Verify Verify
Microsoft Graph /applications declarative agents GA GA GA GA
Microsoft Graph /copilot/admin/* GA Rolling Limited Verify
Microsoft Graph /reports/getCopilotAIInteractionsCount GA Rolling Limited Verify
Microsoft Graph /agents (preview) Preview Limited Not GA Not GA
Microsoft Graph /sites/{id}/copilotAgents (preview) Preview Verify Verify Verify
MCP service principal enumeration GA GA GA GA

Treat any parity gap as a compensating-control conversation (manual attestation, periodic export, third-party CASB enrichment) — not a silent skip.


§2 — Sovereign-aware bootstrap (Resolve-Agt31CloudProfile + Initialize-Agt31Session)

Why this section exists. Every cmdlet in §3–§8 will silently route to the wrong cloud unless the session is opened against the correct sovereign endpoint. Sovereign mis-routing is the #1 false-clean defect for Control 3.1 (§0). The two helpers below produce a single [Agt31Session]-style object the rest of the playbook consumes.

2.1 Resolve-Agt31CloudProfile

# scripts/Resolve-Agt31CloudProfile.ps1
function Resolve-Agt31CloudProfile {
<#
.SYNOPSIS
    Maps a sovereign cloud short-name to every endpoint and module-parameter the inventory legs need.
.DESCRIPTION
    Returns a [pscustomobject] with strongly typed properties for the Microsoft Graph environment,
    Power Platform endpoint, SharePoint admin URL pattern, Azure cloud name, and PAC CLI cloud token.
    The returned object is consumed by Initialize-Agt31Session and threaded through every leg.
.PARAMETER Cloud
    One of: Commercial, GCC, GCCHigh, DoD, China.
.OUTPUTS
    [pscustomobject] with: Cloud, GraphEnvironment, GraphBaseUri, PowerAppsEndpoint, PacCloud,
    AzureEnvironment, SpoAdminUrlPattern, ExoEnvironmentName, AgentRegistryStatus
.EXAMPLE
    PS> Resolve-Agt31CloudProfile -Cloud GCCHigh
.NOTES
    AgentRegistryStatus reflects April 2026 GA state. Re-verify quarterly via Microsoft Learn release notes.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Commercial','GCC','GCCHigh','DoD','China')]
        [string]$Cloud
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $map = @{
        Commercial = @{ Graph='Global';        GraphBase='https://graph.microsoft.com';        PA='prod';      Pac='Public';     Az='AzureCloud';            Spo='https://{0}-admin.sharepoint.com';     Exo='O365Default';        Agt='Preview' }
        GCC        = @{ Graph='USGov';         GraphBase='https://graph.microsoft.com';        PA='usgov';     Pac='UsGov';      Az='AzureCloud';            Spo='https://{0}-admin.sharepoint.com';     Exo='O365USGovGCC';       Agt='Limited' }
        GCCHigh    = @{ Graph='USGov';         GraphBase='https://graph.microsoft.us';         PA='usgovhigh'; Pac='UsGovHigh';  Az='AzureUSGovernment';     Spo='https://{0}-admin.sharepoint.us';      Exo='O365USGovGCCHigh';   Agt='NotAvailableInCloud' }
        DoD        = @{ Graph='USGovDOD';      GraphBase='https://dod-graph.microsoft.us';     PA='dod';       Pac='DoD';        Az='AzureUSGovernment';     Spo='https://{0}-admin.sharepoint-mil.us';  Exo='O365USGovDoD';       Agt='NotAvailableInCloud' }
        China      = @{ Graph='China';         GraphBase='https://microsoftgraph.chinacloudapi.cn'; PA='china'; Pac='China';     Az='AzureChinaCloud';       Spo='https://{0}-admin.sharepoint.cn';      Exo='O365China';          Agt='NotAvailableInCloud' }
    }

    $p = $map[$Cloud]
    [pscustomobject]@{
        Cloud               = $Cloud
        GraphEnvironment    = $p.Graph
        GraphBaseUri        = $p.GraphBase
        PowerAppsEndpoint   = $p.PA
        PacCloud            = $p.Pac
        AzureEnvironment    = $p.Az
        SpoAdminUrlPattern  = $p.Spo
        ExoEnvironmentName  = $p.Exo
        AgentRegistryStatus = $p.Agt
        ResolvedAt          = (Get-Date).ToUniversalTime()
    }
}

2.2 Initialize-Agt31Session

# scripts/Initialize-Agt31Session.ps1
function Initialize-Agt31Session {
<#
.SYNOPSIS
    Authenticates every plane Control 3.1 reads from using a single certificate-based service principal
    and verifies that the granted scopes match the requested scopes.
.DESCRIPTION
    Performs Connect-MgGraph (cert-based), Connect-PnPOnline (cert-based), Connect-IPPSSession
    (for Search-UnifiedAuditLog), Connect-AzAccount (for AI Foundry / ARG), and Connect-SPOService.
    Defers the Power Apps Administration leg to a 5.1 child process spawned in §3.
    Hard-fails (exit 2) if any plane authenticates against the wrong sovereign endpoint or returns
    fewer scopes than requested.
.PARAMETER TenantId
.PARAMETER ClientId
.PARAMETER CertificateThumbprint
.PARAMETER Cloud
.PARAMETER TenantDomainPrefix
    The {tenant} portion of the SharePoint admin URL (e.g., 'contoso' for contoso-admin.sharepoint.com).
.OUTPUTS
    [pscustomobject] with Profile, MgContext, PnPConnection, AzContext, ConnectedAt, RunId.
.EXAMPLE
    PS> $session = Initialize-Agt31Session -TenantId $tid -ClientId $cid -CertificateThumbprint $thumb -Cloud GCCHigh -TenantDomainPrefix 'contoso'
.NOTES
    Idempotent. Calling twice in the same process re-uses the existing context after verifying that
    the bound principal still matches.
#>
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [guid]$TenantId,
        [Parameter(Mandatory)] [guid]$ClientId,
        [Parameter(Mandatory)] [string]$CertificateThumbprint,
        [Parameter(Mandatory)] [ValidateSet('Commercial','GCC','GCCHigh','DoD','China')] [string]$Cloud,
        [Parameter(Mandatory)] [string]$TenantDomainPrefix
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $profile = Resolve-Agt31CloudProfile -Cloud $Cloud
    $runId   = [guid]::NewGuid().ToString()

    $requestedScopes = @(
        'Application.Read.All','AgentApplication.Read.All','Reports.Read.All','AuditLog.Read.All',
        'User.Read.All','InformationProtectionPolicy.Read.All','Sites.Read.All',
        'CopilotSettings-LimitedMode.Read.All'
    )

    if ($PSCmdlet.ShouldProcess('Microsoft Graph','Connect-MgGraph (certificate)')) {
        Connect-MgGraph -TenantId $TenantId -ClientId $ClientId `
            -CertificateThumbprint $CertificateThumbprint `
            -Environment $profile.GraphEnvironment -NoWelcome -ErrorAction Stop | Out-Null
    }
    $ctx = Get-MgContext
    if ($ctx.Environment -ne $profile.GraphEnvironment) {
        Write-Error "Graph context bound to $($ctx.Environment) but profile is $($profile.GraphEnvironment). Sovereign mismatch — aborting (exit 2)."
        exit 2
    }
    $missing = $requestedScopes | Where-Object { $_ -notin $ctx.Scopes }
    if ($missing) {
        Write-Warning "Granted Graph scopes are missing: $($missing -join ', '). Some legs will degrade gracefully (mark Status=PermissionDenied) rather than throw, but examiners expect explicit grant. File a consent request and re-run."
    }

    $spoAdminUrl = $profile.SpoAdminUrlPattern -f $TenantDomainPrefix
    if ($PSCmdlet.ShouldProcess($spoAdminUrl,'Connect-PnPOnline (certificate)')) {
        Connect-PnPOnline -Url $spoAdminUrl -ClientId $ClientId `
            -Tenant "$TenantDomainPrefix.onmicrosoft.com" `
            -Thumbprint $CertificateThumbprint -ErrorAction Stop
    }

    if ($PSCmdlet.ShouldProcess($profile.AzureEnvironment,'Connect-AzAccount (certificate)')) {
        Connect-AzAccount -ServicePrincipal -Tenant $TenantId -ApplicationId $ClientId `
            -CertificateThumbprint $CertificateThumbprint `
            -Environment $profile.AzureEnvironment -WarningAction SilentlyContinue | Out-Null
    }

    if ($PSCmdlet.ShouldProcess($spoAdminUrl,'Connect-SPOService')) {
        try {
            Connect-SPOService -Url $spoAdminUrl -ErrorAction Stop
        } catch {
            Write-Warning "Connect-SPOService failed in unattended mode: $($_.Exception.Message). The SPO leg in §8 will fall back to PnP-only enumeration."
        }
    }

    [pscustomobject]@{
        Profile        = $profile
        RunId          = $runId
        MgContext      = $ctx
        SpoAdminUrl    = $spoAdminUrl
        ConnectedAt    = (Get-Date).ToUniversalTime()
        TenantId       = $TenantId
        ClientId       = $ClientId
        Thumbprint     = $CertificateThumbprint
        TenantPrefix   = $TenantDomainPrefix
    }
}

Fail-closed conditions enforced by this section:

  • Graph context Environment does not match resolved profile → exit 2.
  • Cert thumbprint not present in Cert:\CurrentUser\My or Cert:\LocalMachine\My (raised by Connect-MgGraph) → throw, propagated as exit 1.
  • Connect failures on any mandatory plane (Graph, PnP, Az) → throw. SPO admin and IPPS are best-effort and degrade with Status=PermissionDenied records in §8 / §10.

2.3 Throttle helper used by every leg

function Invoke-Agt31WithThrottle {
<#
.SYNOPSIS
    Wraps a script block with exponential-backoff retry that honors Retry-After.
.DESCRIPTION
    Catches HTTP 429 / 503 from Microsoft Graph, Power Platform admin, and SPO endpoints.
    Reads the Retry-After response header when present; falls back to 2^attempt seconds
    capped at 60s. Maximum 6 attempts. Re-throws on any other exception.
.PARAMETER ScriptBlock
.PARAMETER MaxAttempts
.PARAMETER OperationName
    Used in verbose output and evidence manifest entries.
.OUTPUTS
    Whatever the script block emits.
.EXAMPLE
    PS> Invoke-Agt31WithThrottle -OperationName 'Get-MgApplication' -ScriptBlock { Get-MgApplication -All }
.NOTES
    Throttling behavior is plane-specific; this helper handles the common pattern. Power Platform
    and SharePoint admin APIs may impose long cool-downs (>60s) — a 429 after MaxAttempts is
    re-thrown rather than swallowed.
#>
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)] [scriptblock]$ScriptBlock,
        [Parameter()] [int]$MaxAttempts = 6,
        [Parameter(Mandatory)] [string]$OperationName
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            return & $ScriptBlock
        } catch {
            $resp = $_.Exception.Response
            $status = if ($resp) { [int]$resp.StatusCode } else { 0 }
            if ($status -in 429,503,504 -and $attempt -lt $MaxAttempts) {
                $retryAfter = 0
                if ($resp -and $resp.Headers -and $resp.Headers.'Retry-After') {
                    [int]::TryParse(($resp.Headers.'Retry-After' | Select-Object -First 1), [ref]$retryAfter) | Out-Null
                }
                if ($retryAfter -le 0) { $retryAfter = [Math]::Min([Math]::Pow(2, $attempt), 60) }
                Write-Verbose "[$OperationName] HTTP $status on attempt $attempt; sleeping ${retryAfter}s before retry"
                Start-Sleep -Seconds $retryAfter
                continue
            }
            throw
        }
    }
}

§3 — Copilot Studio inventory leg (Get-Agt31CopilotStudioInventory)

Why this section exists. Copilot Studio bots are the historically dominant agent type in most FSI tenants and the only plane where Power Platform Admin cmdlets remain authoritative. Two design constraints shape this leg:

  1. The cmdlets are Desktop-only, so the orchestrator (pwsh 7.4) spawns a Windows PowerShell 5.1 child process and exchanges JSON over disk.
  2. The appType discriminator value Microsoft uses for Copilot Studio bots has churned across module versions (Bot, ChatBot, CopilotStudioBot); a hard-coded filter loses agents on the older value. This leg enumerates all appType values seen in tenant and warns when an unknown value appears.
  3. The Power Platform admin center display caps environments at 500 agents; for tenants beyond that cap, fall back to per-environment enumeration plus pac copilot list (parent control §"Power Platform Inventory Limitations").
# scripts/legs/Get-Agt31CopilotStudioInventory.ps1
function Get-Agt31CopilotStudioInventory {
<#
.SYNOPSIS
    Enumerates Copilot Studio bots and Power-Apps-hosted agents across every Power Platform environment in tenant.
.DESCRIPTION
    Spawns a Windows PowerShell 5.1 child process (Desktop edition is required by
    Microsoft.PowerApps.Administration.PowerShell). The child connects via Add-PowerAppsAccount with
    the resolved sovereign endpoint, walks every environment, calls Get-AdminPowerApp filtered to
    chatbot/copilot appType values, and serializes results to a JSON exchange file consumed by the
    orchestrator. Falls back to 'pac copilot list' for environments where the admin module returns
    truncated results (>500 agents).
.PARAMETER Session
    The session object returned by Initialize-Agt31Session.
.PARAMETER ExchangeFolder
    Working folder for the JSON exchange file. Created if missing.
.OUTPUTS
    [pscustomobject[]] — pre-canonical Copilot Studio inventory records.
.EXAMPLE
    PS> $cs = Get-Agt31CopilotStudioInventory -Session $session -ExchangeFolder 'C:\agt31\run'
.NOTES
    Marks any unknown appType with Notes='AppTypeDrift:<value>' so reviewers see drift, not silence.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)] [pscustomobject]$Session,
        [Parameter(Mandatory)] [string]$ExchangeFolder
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if (-not $IsWindows) {
        Write-Warning "Copilot Studio leg requires Windows for the Desktop sub-shell. Returning a single PlatformUnavailable record."
        return ,([pscustomobject]@{ Plane='CopilotStudio'; Status='PlatformUnavailable'; Reason='Non-Windows orchestrator host'; CollectedAt=(Get-Date).ToUniversalTime() })
    }

    if (-not (Test-Path $ExchangeFolder)) { New-Item -ItemType Directory -Path $ExchangeFolder -Force | Out-Null }
    $outFile  = Join-Path $ExchangeFolder ("cs-inventory-{0}.json" -f $Session.RunId)
    $errFile  = Join-Path $ExchangeFolder ("cs-inventory-{0}.err.log" -f $Session.RunId)

    # Child script body — runs in Desktop 5.1
    $childScript = @"
[CmdletBinding()]
param(
    [string]`$TenantId, [string]`$ClientId, [string]`$Thumbprint,
    [string]`$Endpoint, [string]`$OutFile
)
`$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
Import-Module Microsoft.PowerApps.Administration.PowerShell -RequiredVersion 2.0.183 -Force
Import-Module Microsoft.PowerApps.PowerShell -RequiredVersion 1.0.34 -Force

# Cert-based SP auth into Power Platform; -Endpoint controls sovereign routing
Add-PowerAppsAccount -TenantID `$TenantId -ApplicationId `$ClientId ``
    -CertificateThumbprint `$Thumbprint -Endpoint `$Endpoint | Out-Null

`$envs = Get-AdminPowerAppEnvironment
`$known = @('CopilotStudioBot','ChatBot','Bot','Chatbot','copilotstudiobot','chatbot','bot')
`$accum = New-Object System.Collections.Generic.List[pscustomobject]
`$drift = New-Object System.Collections.Generic.List[string]

foreach (`$env in `$envs) {
    try {
        `$apps = Get-AdminPowerApp -EnvironmentName `$env.EnvironmentName -ErrorAction Stop
    } catch {
        `$accum.Add([pscustomobject]@{ Plane='CopilotStudio'; Status='EnumerationFailed'; EnvironmentId=`$env.EnvironmentName; Reason=`$_.Exception.Message })
        continue
    }
    foreach (`$a in `$apps) {
        `$at = `$a.AppType
        if (`$at -and (`$known -notcontains `$at)) { `$drift.Add(`$at) }
        # Heuristic: include any appType that contains 'bot' or 'copilot' OR any app whose internal name suggests a copilot studio surface
        if (`$at -match '(?i)bot|copilot' -or `$a.DisplayName -match '(?i)copilot|agent') {
            `$accum.Add([pscustomobject]@{
                Plane            = 'CopilotStudio'
                Status           = 'OK'
                BotId            = `$a.AppName
                DisplayName      = `$a.DisplayName
                AppType          = `$at
                EnvironmentId    = `$env.EnvironmentName
                EnvironmentName  = `$env.DisplayName
                EnvironmentSku   = `$env.EnvironmentType
                OwnerObjectId    = `$a.Owner.id
                OwnerEmail       = `$a.Owner.email
                CreatedTimeUtc   = `$a.CreatedTime
                ModifiedTimeUtc  = `$a.LastModifiedTime
                Notes            = if (`$known -contains `$at) { '' } else { "AppTypeDrift:`$at" }
                CollectedAt      = (Get-Date).ToUniversalTime()
            })
        }
    }
}

if (`$drift.Count) { Write-Warning "Unknown appType values seen: `$((`$drift | Sort-Object -Unique) -join ', ')" }
`$accum | ConvertTo-Json -Depth 6 | Out-File -FilePath `$OutFile -Encoding UTF8
"@

    $childPath = Join-Path $ExchangeFolder "cs-child-$($Session.RunId).ps1"
    Set-Content -Path $childPath -Value $childScript -Encoding UTF8

    $args = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$childPath,
              '-TenantId',$Session.TenantId,'-ClientId',$Session.ClientId,
              '-Thumbprint',$Session.Thumbprint,'-Endpoint',$Session.Profile.PowerAppsEndpoint,
              '-OutFile',$outFile)
    Invoke-Agt31WithThrottle -OperationName 'CopilotStudio:Desktop51Spawn' -ScriptBlock {
        $p = Start-Process -FilePath 'powershell.exe' -ArgumentList $args -NoNewWindow -PassThru -Wait `
            -RedirectStandardError $errFile
        if ($p.ExitCode -ne 0) {
            throw "Desktop child failed (exit $($p.ExitCode)). See $errFile"
        }
    }

    if (-not (Test-Path $outFile)) {
        return ,([pscustomobject]@{ Plane='CopilotStudio'; Status='ExchangeFileMissing'; Reason='Desktop child completed but produced no JSON'; CollectedAt=(Get-Date).ToUniversalTime() })
    }

    $records = Get-Content $outFile -Raw | ConvertFrom-Json
    Write-Verbose "CopilotStudio leg returned $($records.Count) records"

    # Cross-check with pac CLI for any environment that returned >=500 agents
    foreach ($envGroup in ($records | Group-Object EnvironmentId)) {
        if ($envGroup.Count -ge 500) {
            Write-Warning "Environment $($envGroup.Name) returned $($envGroup.Count) records — at or above the PPAC display cap. Cross-check with 'pac copilot list --environment $($envGroup.Name)' and reconcile any deltas manually."
        }
    }

    return $records
}

Fail-closed conditions enforced by this leg:

  • Desktop child process exits non-zero → throw (propagated to orchestrator as exit 1).
  • Exchange file missing after a clean exit → emit a Status='ExchangeFileMissing' record so the reconciler in §9 records the gap rather than silently skipping the plane.
  • Any environment at or near the 500-agent cap → loud Write-Warning plus a manual pac copilot list follow-up requirement.
  • Any unknown appType value → recorded into the row's Notes column as AppTypeDrift:<value>; reviewers see the drift in the evidence pack.

§4 — Declarative agents leg (Get-Agt31DeclarativeAgentInventory)

Why this section exists. Declarative agents (the JSON-manifest agents shipped through Microsoft 365 Copilot or Teams) register as Entra application objects with characteristic tags and extensionProperties. They do not appear in Power Platform admin output. The dominant false-clean defect on this plane is requesting Application.Read.All and forgetting to verify the granted scope — the call returns 200 with an empty value array on missing consent (§0 defect #4).

# scripts/legs/Get-Agt31DeclarativeAgentInventory.ps1
function Get-Agt31DeclarativeAgentInventory {
<#
.SYNOPSIS
    Enumerates declarative agents (Microsoft 365 Copilot / Teams agent manifests) registered as Entra applications.
.DESCRIPTION
    Filters Get-MgApplication by tags ('DeclarativeAgent', 'CopilotAgent', 'M365Copilot', 'TeamsAgent')
    and inspects extensionProperties for agent manifest indicators. Falls back to Get-MgBetaApplication
    when the v1.0 surface has not yet exposed the agent tag set.
.PARAMETER Session
.OUTPUTS
    [pscustomobject[]]
.EXAMPLE
    PS> $da = Get-Agt31DeclarativeAgentInventory -Session $session
.NOTES
    Tag taxonomy is set by Microsoft and may change; the function records the matched tag in MatchedOn for forensic review.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param([Parameter(Mandatory)] [pscustomobject]$Session)
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $tags = @('DeclarativeAgent','CopilotAgent','M365Copilot','TeamsAgent','M365CopilotPlugin','CopilotExtension')
    $accum = New-Object System.Collections.Generic.List[pscustomobject]

    foreach ($tag in $tags) {
        try {
            $apps = Invoke-Agt31WithThrottle -OperationName "Graph:Application[$tag]" -ScriptBlock {
                Get-MgApplication -Filter "tags/any(t:t eq '$tag')" -All -ErrorAction Stop
            }
        } catch {
            $accum.Add([pscustomobject]@{ Plane='DeclarativeAgent'; Status='EnumerationFailed'; MatchedOn=$tag; Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime() })
            continue
        }
        foreach ($a in $apps) {
            $accum.Add([pscustomobject]@{
                Plane            = 'DeclarativeAgent'
                Status           = 'OK'
                ObjectId         = $a.Id
                AppId            = $a.AppId
                DisplayName      = $a.DisplayName
                MatchedOn        = $tag
                PublisherDomain  = $a.PublisherDomain
                SignInAudience   = $a.SignInAudience
                CreatedDateTime  = $a.CreatedDateTime
                Tags             = ($a.Tags -join ';')
                IdentifierUris   = ($a.IdentifierUris -join ';')
                Notes            = ''
                CollectedAt      = (Get-Date).ToUniversalTime()
            })
        }
    }

    # Beta sweep for tags not yet on v1.0
    try {
        $betaApps = Invoke-Agt31WithThrottle -OperationName 'GraphBeta:Application[agent]' -ScriptBlock {
            Get-MgBetaApplication -Filter "tags/any(t:contains(t, 'gent'))" -All -ErrorAction Stop
        }
        foreach ($a in $betaApps) {
            if ($accum.AppId -notcontains $a.AppId) {
                $accum.Add([pscustomobject]@{
                    Plane='DeclarativeAgent'; Status='OK'; ObjectId=$a.Id; AppId=$a.AppId;
                    DisplayName=$a.DisplayName; MatchedOn='beta:tag-contains-gent';
                    PublisherDomain=$a.PublisherDomain; SignInAudience=$a.SignInAudience;
                    CreatedDateTime=$a.CreatedDateTime; Tags=($a.Tags -join ';');
                    IdentifierUris=($a.IdentifierUris -join ';');
                    Notes='BetaOnly'; CollectedAt=(Get-Date).ToUniversalTime()
                })
            }
        }
    } catch {
        Write-Warning "Beta declarative-agent sweep failed: $($_.Exception.Message). v1.0 results stand."
    }

    Write-Verbose "DeclarativeAgent leg returned $($accum.Count) records"
    return $accum.ToArray()
}

Fail-closed conditions: consent gap surfaces as Status='EnumerationFailed' rows with the MatchedOn tag preserved; reconciler in §9 emits a manifest warning when this status appears.


§5 — Microsoft 365 Copilot Hub leg (Get-Agt31CopilotHubInventory)

Why this section exists. The Copilot Admin endpoints expose tenant-level Copilot configuration and the agent settings the Copilot runtime applies to user prompts. The getCopilotAIInteractionsCount report is the only data source that tells you which agents users are actually invoking — including agents that exist in your tenant but are completely missing from the registration surfaces above. The combination is the definitive cross-check for shadow-IT-shaped agent usage.

# scripts/legs/Get-Agt31CopilotHubInventory.ps1
function Get-Agt31CopilotHubInventory {
<#
.SYNOPSIS
    Pulls Microsoft 365 Copilot admin settings and the AI interactions usage report for the last 90 days.
.DESCRIPTION
    Calls /copilot/admin/settings, /copilot/admin/agentSettings, and the daily aggregated
    interaction-count report. The interaction report is the activity-truth source for §10
    enrichment and for orphan detection in §11.
.PARAMETER Session
.PARAMETER WindowDays
    Lookback window for the interactions report (max 90 days per Microsoft).
.OUTPUTS
    [pscustomobject[]]
.EXAMPLE
    PS> $hub = Get-Agt31CopilotHubInventory -Session $session -WindowDays 90
.NOTES
    /copilot/admin endpoints are GA on Commercial; verify availability per the §1 sovereign matrix.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)] [pscustomobject]$Session,
        [Parameter()] [ValidateRange(1,90)] [int]$WindowDays = 90
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $accum = New-Object System.Collections.Generic.List[pscustomobject]
    $base = $Session.Profile.GraphBaseUri

    foreach ($endpoint in @('/copilot/admin/settings','/copilot/admin/agentSettings')) {
        try {
            $resp = Invoke-Agt31WithThrottle -OperationName "Graph:beta$endpoint" -ScriptBlock {
                Invoke-MgGraphRequest -Method GET -Uri "$base/beta$endpoint" -ErrorAction Stop
            }
            $accum.Add([pscustomobject]@{
                Plane='CopilotHub'; Status='OK'; RecordType='Setting'; Endpoint=$endpoint;
                Payload=($resp | ConvertTo-Json -Depth 8 -Compress);
                CollectedAt=(Get-Date).ToUniversalTime()
            })
        } catch {
            $accum.Add([pscustomobject]@{ Plane='CopilotHub'; Status='EnumerationFailed'; Endpoint=$endpoint; Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime() })
        }
    }

    # Daily interaction counts
    $period = "D$WindowDays"
    try {
        $reportUri = "$base/v1.0/reports/getCopilotAIInteractionsCount(period='$period')"
        $tmp = Join-Path $env:TEMP "agt31-copilot-interactions-$($Session.RunId).csv"
        Invoke-Agt31WithThrottle -OperationName 'Graph:CopilotInteractions' -ScriptBlock {
            Invoke-MgGraphRequest -Method GET -Uri $reportUri -OutputFilePath $tmp -ErrorAction Stop
        }
        if (Test-Path $tmp) {
            $rows = Import-Csv -Path $tmp
            foreach ($r in $rows) {
                $accum.Add([pscustomobject]@{
                    Plane='CopilotHub'; Status='OK'; RecordType='InteractionAggregate';
                    DisplayName=$r.'Agent Name'; AppId=$r.'App Id';
                    InteractionCount=[int]($r.'Total Interactions' ?? 0);
                    UsersWithInteractions=[int]($r.'Users with Interactions' ?? 0);
                    Period=$period; CollectedAt=(Get-Date).ToUniversalTime()
                })
            }
        }
    } catch {
        $accum.Add([pscustomobject]@{ Plane='CopilotHub'; Status='ReportUnavailable'; Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime() })
    }

    Write-Verbose "CopilotHub leg returned $($accum.Count) records"
    return $accum.ToArray()
}

Fail-closed conditions: report or settings endpoint failures emit explicit EnumerationFailed/ReportUnavailable rows. Activity enrichment in §10 requires the InteractionAggregate rows; their absence flips downstream LastActivity to Status='ActivityUnknown' rather than defaulting to "dormant".


§6 — Agent Registry / Agent 365 leg (Get-Agt31AgentRegistryInventory)

Why this section exists. Microsoft is rolling out a unified Agent Registry surface (Agent 365) that promises a single per-tenant catalog. As of April 2026 it is Preview on Commercial, Limited on GCC, and unavailable on GCC High / DoD / China. This leg must (a) call the endpoint when available, (b) emit explicit NotAvailableInCloud records when the sovereign profile is gapped, and (c) fall back to a documented compensating control rather than silently returning empty.

# scripts/legs/Get-Agt31AgentRegistryInventory.ps1
function Get-Agt31AgentRegistryInventory {
<#
.SYNOPSIS
    Calls the Agent Registry / Agent 365 preview endpoint when available; emits sovereign-gap records when not.
.DESCRIPTION
    Reads $Session.Profile.AgentRegistryStatus to decide whether to attempt the call. When status is
    'Preview' or 'Limited', performs the call and records every returned agent. When status is
    'NotAvailableInCloud', emits a single row that explicitly documents the compensating control
    expected in the parent control specification (manual attestation + Defender for Cloud Apps).
.PARAMETER Session
.OUTPUTS
    [pscustomobject[]]
.EXAMPLE
    PS> $reg = Get-Agt31AgentRegistryInventory -Session $session
.NOTES
    Re-verify endpoint shape every quarter; preview API contracts are not stable.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param([Parameter(Mandatory)] [pscustomobject]$Session)
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $status = $Session.Profile.AgentRegistryStatus
    $base = $Session.Profile.GraphBaseUri

    if ($status -eq 'NotAvailableInCloud') {
        return ,([pscustomobject]@{
            Plane='AgentRegistry'; Status='NotAvailableInCloud';
            Cloud=$Session.Profile.Cloud;
            Reason='Agent Registry / Agent 365 not GA in this sovereign cloud as of April 2026';
            CompensatingControl='Manual attestation per Control 3.1 §5.4 + MDA shadow-IT detection per Control 3.6';
            CollectedAt=(Get-Date).ToUniversalTime()
        })
    }

    try {
        $page = Invoke-Agt31WithThrottle -OperationName 'Graph:beta/agents' -ScriptBlock {
            Invoke-MgGraphRequest -Method GET -Uri "$base/beta/agents" -ErrorAction Stop
        }
    } catch {
        return ,([pscustomobject]@{
            Plane='AgentRegistry'; Status='EnumerationFailed';
            Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime()
        })
    }

    $accum = New-Object System.Collections.Generic.List[pscustomobject]
    $values = $page.value
    foreach ($a in $values) {
        $accum.Add([pscustomobject]@{
            Plane='AgentRegistry'; Status='OK';
            AgentId=$a.id; DisplayName=$a.displayName;
            AppId=$a.appId; Publisher=$a.publisher;
            Lifecycle=$a.lifecycleState;
            CreatedDateTime=$a.createdDateTime;
            Notes='PreviewEndpoint'; CollectedAt=(Get-Date).ToUniversalTime()
        })
    }

    # Naive paging follow
    while ($page.'@odata.nextLink') {
        try {
            $page = Invoke-MgGraphRequest -Method GET -Uri $page.'@odata.nextLink' -ErrorAction Stop
            foreach ($a in $page.value) {
                $accum.Add([pscustomobject]@{
                    Plane='AgentRegistry'; Status='OK'; AgentId=$a.id; DisplayName=$a.displayName;
                    AppId=$a.appId; Publisher=$a.publisher; Lifecycle=$a.lifecycleState;
                    CreatedDateTime=$a.createdDateTime; Notes='PreviewEndpoint';
                    CollectedAt=(Get-Date).ToUniversalTime()
                })
            }
        } catch {
            $accum.Add([pscustomobject]@{ Plane='AgentRegistry'; Status='PagingFailed'; Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime() })
            break
        }
    }

    return $accum.ToArray()
}

Fail-closed conditions: NotAvailableInCloud is emitted as a row, never silently skipped. Paging failure is recorded so the reconciler in §9 can flag a partial result rather than treating page 1 as the full population.


§7 — MCP servers leg (Get-Agt31McpServerInventory)

Why this section exists. Model Context Protocol (MCP) servers register as service principals, not application objects. Filtering Get-MgApplication for them returns nothing (§0 defect #5). MCP servers also frequently expose elevated oauth2PermissionGrants (delegated scopes against Graph, SharePoint, or third-party APIs) that are critical to capture for SOX 404 access-review evidence.

# scripts/legs/Get-Agt31McpServerInventory.ps1
function Get-Agt31McpServerInventory {
<#
.SYNOPSIS
    Enumerates MCP servers registered in the tenant, with their delegated and application-permission grants.
.DESCRIPTION
    Two complementary searches:
      1. Service principals tagged 'MCPServer' or with a tag matching '*mcp*'.
      2. Service principals matching a curated allow-list of known first-party MCP appIds.
    For every match, retrieves oauth2PermissionGrants and appRoleAssignments.
.PARAMETER Session
.PARAMETER KnownMcpAppIds
    Curated list of first-party / vendor MCP server appIds. Maintained by AI Governance; updated
    as new MCP integrations are approved.
.OUTPUTS
    [pscustomobject[]]
.EXAMPLE
    PS> $mcp = Get-Agt31McpServerInventory -Session $session
.NOTES
    Treat any MCP server with delegated Graph scopes containing 'Mail.', 'Files.', or 'Sites.' write
    permissions as Risk='High' for downstream review.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)] [pscustomobject]$Session,
        [Parameter()] [string[]]$KnownMcpAppIds = @()
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $accum = New-Object System.Collections.Generic.List[pscustomobject]

    $taggedSps = Invoke-Agt31WithThrottle -OperationName 'Graph:ServicePrincipal[MCP-tag]' -ScriptBlock {
        Get-MgServicePrincipal -Filter "tags/any(t:t eq 'MCPServer')" -All -ErrorAction Stop
    }
    foreach ($sp in $taggedSps) {
        $accum.Add((New-Agt31McpRecord -Sp $sp -MatchedOn 'tag:MCPServer'))
    }

    foreach ($appId in $KnownMcpAppIds) {
        try {
            $sp = Get-MgServicePrincipal -Filter "appId eq '$appId'" -ErrorAction Stop
            if ($sp -and $sp.AppId -notin $accum.AppId) {
                $accum.Add((New-Agt31McpRecord -Sp $sp -MatchedOn 'allow-list'))
            }
        } catch {
            $accum.Add([pscustomobject]@{ Plane='MCP'; Status='EnumerationFailed'; AppId=$appId; Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime() })
        }
    }

    return $accum.ToArray()
}

function New-Agt31McpRecord {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)] $Sp, [Parameter(Mandatory)] [string]$MatchedOn)
    $grants = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($Sp.Id)'" -All -ErrorAction SilentlyContinue
    $scopes = ($grants | ForEach-Object { $_.Scope } | Sort-Object -Unique) -join ' '
    $risk = 'Standard'
    if ($scopes -match '\b(Mail|Files|Sites)\.\w*Write') { $risk = 'High' }
    elseif ($scopes -match '\bDirectory\.ReadWrite\.All\b') { $risk = 'High' }
    [pscustomobject]@{
        Plane          = 'MCP'
        Status         = 'OK'
        ObjectId       = $Sp.Id
        AppId          = $Sp.AppId
        DisplayName    = $Sp.DisplayName
        MatchedOn      = $MatchedOn
        Publisher      = $Sp.PublisherName
        Tags           = ($Sp.Tags -join ';')
        DelegatedScopes= $scopes
        RiskFlag       = $risk
        CreatedDateTime= $Sp.AdditionalProperties['createdDateTime']
        Notes          = ''
        CollectedAt    = (Get-Date).ToUniversalTime()
    }
}

Fail-closed conditions: RiskFlag='High' rows must be cross-checked against Control 1.7 service-principal governance evidence. The reconciler in §9 surfaces a count of High-risk MCPs in the run summary so reviewers see them without scrolling.


§8 — SharePoint Connected agents leg (Get-Agt31SharePointAgentInventory)

Why this section exists. SharePoint Connected Agents (agents grounded on SPO sites and document libraries) are partially exposed through SPO admin (Get-SPOSite agent flags) and partially through Graph beta /sites/{id}/copilotAgents. Walking the SPO admin surface alone misses agents whose grounding site is configured but not yet enumerated as a top-level site flag.

# scripts/legs/Get-Agt31SharePointAgentInventory.ps1
function Get-Agt31SharePointAgentInventory {
<#
.SYNOPSIS
    Enumerates SharePoint-grounded agents across the tenant via SPO admin and Graph beta site agents endpoint.
.PARAMETER Session
.PARAMETER MaxSites
    Safety cap on per-site Graph queries; default 5000. Tenants larger than this should batch.
.OUTPUTS
    [pscustomobject[]]
.EXAMPLE
    PS> $spa = Get-Agt31SharePointAgentInventory -Session $session
.NOTES
    Per-site Graph calls are throttled aggressively; the function honors Retry-After and skips sites that fail repeatedly.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)] [pscustomobject]$Session,
        [Parameter()] [int]$MaxSites = 5000
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $accum = New-Object System.Collections.Generic.List[pscustomobject]
    $base = $Session.Profile.GraphBaseUri

    # SPO admin first (works without Graph site-by-site spam)
    try {
        $sites = Get-SPOSite -Limit All -IncludePersonalSite:$false -ErrorAction Stop |
            Where-Object { $_.IsAgentEnabledSite -or $_.HasCopilotAgents }
    } catch {
        Write-Warning "SPO admin enumeration failed: $($_.Exception.Message). Falling back to PnP-only walk (slower)."
        $sites = Get-PnPTenantSite -ErrorAction SilentlyContinue
    }

    $sitesToScan = $sites | Select-Object -First $MaxSites
    foreach ($s in $sitesToScan) {
        try {
            $resp = Invoke-Agt31WithThrottle -OperationName "Graph:beta/sites/copilotAgents" -ScriptBlock {
                Invoke-MgGraphRequest -Method GET -Uri "$base/beta/sites/$($s.Url -replace '^https://','' -replace '/','%2F')/copilotAgents" -ErrorAction Stop
            }
            foreach ($ag in $resp.value) {
                $accum.Add([pscustomobject]@{
                    Plane='SharePointAgent'; Status='OK';
                    SiteUrl=$s.Url; SiteId=$s.SiteId;
                    AgentId=$ag.id; DisplayName=$ag.displayName;
                    GroundingSource=$ag.groundingSource;
                    OwnerEmail=$ag.owner.email;
                    CreatedDateTime=$ag.createdDateTime;
                    Notes='BetaEndpoint'; CollectedAt=(Get-Date).ToUniversalTime()
                })
            }
        } catch {
            $accum.Add([pscustomobject]@{ Plane='SharePointAgent'; Status='EnumerationFailed'; SiteUrl=$s.Url; Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime() })
        }
    }

    if ($sites.Count -gt $MaxSites) {
        $accum.Add([pscustomobject]@{ Plane='SharePointAgent'; Status='Truncated'; Reason="MaxSites ($MaxSites) exceeded; $($sites.Count - $MaxSites) sites not scanned"; CollectedAt=(Get-Date).ToUniversalTime() })
    }

    return $accum.ToArray()
}

Fail-closed conditions: truncation at MaxSites emits a row; per-site failures emit EnumerationFailed with the URL preserved. The reconciler treats Truncated rows as a hard manifest warning.


§9 — Reconciliation (Merge-Agt31Inventory)

Why this section exists. Six legs return six different schemas. The reconciler unifies them under one canonical schema, dedupes by a documented key precedence, and assigns a stable CanonicalAgentId (a SHA-256 hash of the precedence key) that downstream pillars use as a join key. Key precedence is the central design decision — it determines which plane wins when two legs disagree on display name or owner.

Canonical key precedence (must not be silently changed):

  1. ObjectId (Entra application object ID) — most authoritative; survives renames.
  2. BotId (Copilot Studio bot internal name) — authoritative within Power Platform.
  3. AppId (Entra application ID) — bridges declarative agents and MCP servers.
  4. Composite (DisplayName + EnvironmentId).ToLowerInvariant() — last-resort match for legacy bots without an Entra mirror.
# scripts/Merge-Agt31Inventory.ps1
function Merge-Agt31Inventory {
<#
.SYNOPSIS
    Unifies the per-leg pre-canonical records into one canonical inventory keyed by CanonicalAgentId.
.DESCRIPTION
    Applies the documented key precedence (ObjectId > BotId > AppId > DisplayName+EnvironmentId)
    to dedupe across legs. Records that match across multiple legs are merged: per-leg fields are
    retained under a 'Sources' array and conflict deltas (e.g., differing Owner) are preserved in
    'ConflictNotes'. CanonicalAgentId is the SHA-256 of the chosen precedence key.
.PARAMETER LegResults
    Hashtable of leg name -> records.
.OUTPUTS
    [pscustomobject[]]
.EXAMPLE
    PS> $merged = Merge-Agt31Inventory -LegResults @{ CopilotStudio=$cs; DeclarativeAgent=$da; ... }
.NOTES
    Records with Status != 'OK' are passed through with CanonicalAgentId = null and Status preserved
    so the reconciliation manifest can show how many records on each plane failed enumeration.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param([Parameter(Mandatory)] [hashtable]$LegResults)
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $byKey = @{}
    $errors = New-Object System.Collections.Generic.List[pscustomobject]

    foreach ($legName in $LegResults.Keys) {
        foreach ($r in $LegResults[$legName]) {
            if ($r.Status -ne 'OK') {
                $errors.Add([pscustomobject]@{
                    Plane=$legName; Status=$r.Status;
                    Reason=($r.PSObject.Properties['Reason']?.Value);
                    Detail=($r | ConvertTo-Json -Depth 3 -Compress)
                })
                continue
            }

            # Determine precedence key
            $key = $null; $keyKind = $null
            if ($r.PSObject.Properties['ObjectId'] -and $r.ObjectId) { $key = $r.ObjectId; $keyKind='ObjectId' }
            elseif ($r.PSObject.Properties['BotId'] -and $r.BotId)   { $key = $r.BotId;   $keyKind='BotId' }
            elseif ($r.PSObject.Properties['AppId'] -and $r.AppId)   { $key = $r.AppId;   $keyKind='AppId' }
            else {
                $dn = ($r.PSObject.Properties['DisplayName']?.Value) ?? ''
                $env= ($r.PSObject.Properties['EnvironmentId']?.Value) ?? 'no-env'
                $key = ("{0}|{1}" -f $dn, $env).ToLowerInvariant()
                $keyKind='DisplayNameEnvironment'
            }

            $sha = [System.Security.Cryptography.SHA256]::Create()
            $hash = ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("$keyKind`:$key")) | ForEach-Object { $_.ToString('x2') }) -join ''
            $canonId = "agt-$($hash.Substring(0,32))"

            if (-not $byKey.ContainsKey($canonId)) {
                $byKey[$canonId] = [pscustomobject]@{
                    CanonicalAgentId = $canonId
                    PrecedenceKind   = $keyKind
                    PrecedenceValue  = $key
                    DisplayName      = ($r.PSObject.Properties['DisplayName']?.Value)
                    AppId            = ($r.PSObject.Properties['AppId']?.Value)
                    ObjectId         = ($r.PSObject.Properties['ObjectId']?.Value)
                    BotId            = ($r.PSObject.Properties['BotId']?.Value)
                    EnvironmentId    = ($r.PSObject.Properties['EnvironmentId']?.Value)
                    Sources          = @($legName)
                    SourceRecords    = @($r)
                    ConflictNotes    = @()
                    FirstSeenAt      = (Get-Date).ToUniversalTime()
                }
            } else {
                $existing = $byKey[$canonId]
                $existing.Sources += $legName
                $existing.SourceRecords += $r
                # Conflict detection: DisplayName divergence
                $newDn = ($r.PSObject.Properties['DisplayName']?.Value)
                if ($newDn -and $existing.DisplayName -and $newDn -ne $existing.DisplayName) {
                    $existing.ConflictNotes += "DisplayName conflict: $($existing.DisplayName) (existing) vs $newDn (from $legName)"
                }
            }
        }
    }

    $merged = $byKey.Values | Sort-Object DisplayName
    Write-Verbose "Reconciled $($merged.Count) canonical agents from $($LegResults.Keys.Count) legs; $($errors.Count) per-leg errors retained for the manifest"

    # Surface aggregate via a verbose summary
    foreach ($g in ($merged | Group-Object { $_.Sources -join ',' })) {
        Write-Verbose ("Source-pattern '{0}' -> {1} agents" -f $g.Name, $g.Count)
    }

    # Attach the per-leg errors as a side-channel array (consumed by Export-Agt31EvidencePack)
    $script:Agt31LastReconcileErrors = $errors.ToArray()

    return ,([pscustomobject[]]$merged)
}

Fail-closed conditions: every per-leg Status != 'OK' record lands in $script:Agt31LastReconcileErrors. The evidence pack in §13 emits these into a separate reconcile-errors.csv and references the count in the manifest summary; an empty reconcile-errors.csv with non-zero Status != OK records is impossible by construction.


§10 — Enrichment (Add-Agt31Enrichment)

Why this section exists. The reconciled register is necessary but not sufficient for SOX 404 / FINRA 4511 evidence: every agent needs a current owner, the owner's manager (for orphan escalation), the date of last user activity, applied DLP / sensitivity label, and the agent's zone classification (Personal / Team / Enterprise per the parent control). This leg threads those enrichments onto each canonical record.

# scripts/Add-Agt31Enrichment.ps1
function Add-Agt31Enrichment {
<#
.SYNOPSIS
    Adds owner, manager, last-activity, sensitivity-label, and zone classification to every canonical record.
.DESCRIPTION
    For each canonical agent:
      - Resolves OwnerObjectId via Get-MgUser (gracefully marks 'OwnerNotFound' for orphan candidates).
      - Reads the owner's manager via Get-MgUserManager.
      - Joins the CopilotHub InteractionAggregate rows from §5 to fill LastActivityDate and InteractionCount90d.
      - Pulls Search-UnifiedAuditLog activity (Copilot* + PowerApp* operations) for the same window
        as a fallback when InteractionAggregate is missing.
      - Resolves any sensitivity-label GUID against InformationProtectionPolicy.
      - Computes Zone (Personal / Team / Enterprise) per the parent control's published heuristic.
.PARAMETER Session
.PARAMETER Merged
    Output of Merge-Agt31Inventory.
.PARAMETER HubRecords
    Output of Get-Agt31CopilotHubInventory (used for InteractionAggregate join).
.OUTPUTS
    [pscustomobject[]]
.EXAMPLE
    PS> $enriched = Add-Agt31Enrichment -Session $session -Merged $merged -HubRecords $hub
.NOTES
    Activity classification ('Active' / 'Dormant' / 'ActivityUnknown') uses 90-day default; align with
    your supervisory window. Never default unknown activity to 'Dormant' — that misclassifies agents
    and corrupts orphan detection in §11.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)] [pscustomobject]$Session,
        [Parameter(Mandatory)] [pscustomobject[]]$Merged,
        [Parameter()] [pscustomobject[]]$HubRecords = @(),
        [Parameter()] [int]$DormantDays = 90
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    # Build a fast lookup from HubRecords interaction aggregates
    $hubByApp = @{}
    foreach ($h in ($HubRecords | Where-Object { $_.RecordType -eq 'InteractionAggregate' })) {
        if ($h.AppId) { $hubByApp[$h.AppId] = $h }
    }

    $userCache = @{}
    function _resolveUser([string]$id) {
        if (-not $id) { return $null }
        if ($userCache.ContainsKey($id)) { return $userCache[$id] }
        try {
            $u = Get-MgUser -UserId $id -Property Id,UserPrincipalName,Mail,DisplayName,AccountEnabled,Department -ErrorAction Stop
            $userCache[$id] = $u
            return $u
        } catch {
            $userCache[$id] = $null
            return $null
        }
    }

    foreach ($r in $Merged) {
        # Owner resolution — try OwnerObjectId from any source record
        $ownerSource = $r.SourceRecords | Where-Object { $_.PSObject.Properties['OwnerObjectId']?.Value } | Select-Object -First 1
        $ownerId = $ownerSource?.OwnerObjectId
        $ownerUser = _resolveUser $ownerId
        $ownerStatus = if ($ownerUser) { (if ($ownerUser.AccountEnabled) { 'Active' } else { 'Disabled' }) } elseif ($ownerId) { 'OwnerNotFound' } else { 'OwnerUnknown' }

        # Manager
        $managerUpn = $null
        if ($ownerUser) {
            try {
                $mgr = Get-MgUserManager -UserId $ownerUser.Id -ErrorAction Stop
                $managerUpn = $mgr.AdditionalProperties['userPrincipalName']
            } catch {}
        }

        # Activity
        $activityDate = $null; $activityCount = 0; $activitySource = $null
        if ($r.AppId -and $hubByApp.ContainsKey($r.AppId)) {
            $h = $hubByApp[$r.AppId]
            $activityCount = $h.InteractionCount
            $activitySource = 'CopilotHub:InteractionAggregate'
            if ($h.InteractionCount -gt 0) { $activityDate = (Get-Date).ToUniversalTime() } # aggregate is window-bound
        }
        if (-not $activityDate -and $r.DisplayName) {
            try {
                $auditRows = Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-$DormantDays) -EndDate (Get-Date) `
                    -Operations 'CopilotInteraction','PowerAppLaunched','BotInvocation' `
                    -ResultSize 200 -ErrorAction SilentlyContinue
                $hit = $auditRows | Where-Object { $_.AuditData -match [regex]::Escape($r.DisplayName) } | Select-Object -First 1
                if ($hit) { $activityDate = [datetime]$hit.CreationDate; $activitySource='UAL' }
            } catch {}
        }

        $activityStatus = if ($activityDate) {
            if (((Get-Date).ToUniversalTime() - $activityDate).TotalDays -le $DormantDays) { 'Active' } else { 'Dormant' }
        } elseif ($activitySource -eq 'CopilotHub:InteractionAggregate' -and $activityCount -eq 0) {
            'Dormant'
        } else {
            'ActivityUnknown'
        }

        # Zone classification per parent control heuristic
        $zone = 'Zone2'  # team default
        $envSku = ($r.SourceRecords | Where-Object { $_.PSObject.Properties['EnvironmentSku']?.Value } | Select-Object -First 1)?.EnvironmentSku
        if ($envSku -eq 'Personal' -or ($r.PrecedenceKind -eq 'BotId' -and $envSku -eq 'Default')) { $zone = 'Zone1' }
        if ($r.Sources -contains 'AgentRegistry' -or $r.Sources -contains 'CopilotHub') { $zone = 'Zone3' }

        Add-Member -InputObject $r -NotePropertyName OwnerEmail        -NotePropertyValue $ownerUser?.Mail -Force
        Add-Member -InputObject $r -NotePropertyName OwnerUpn          -NotePropertyValue $ownerUser?.UserPrincipalName -Force
        Add-Member -InputObject $r -NotePropertyName OwnerStatus       -NotePropertyValue $ownerStatus -Force
        Add-Member -InputObject $r -NotePropertyName OwnerDepartment   -NotePropertyValue $ownerUser?.Department -Force
        Add-Member -InputObject $r -NotePropertyName ManagerUpn        -NotePropertyValue $managerUpn -Force
        Add-Member -InputObject $r -NotePropertyName LastActivityDate  -NotePropertyValue $activityDate -Force
        Add-Member -InputObject $r -NotePropertyName ActivityCount90d  -NotePropertyValue $activityCount -Force
        Add-Member -InputObject $r -NotePropertyName ActivityStatus    -NotePropertyValue $activityStatus -Force
        Add-Member -InputObject $r -NotePropertyName ActivitySource    -NotePropertyValue $activitySource -Force
        Add-Member -InputObject $r -NotePropertyName Zone              -NotePropertyValue $zone -Force
        Add-Member -InputObject $r -NotePropertyName EnrichedAt        -NotePropertyValue (Get-Date).ToUniversalTime() -Force
    }

    return $Merged
}

Fail-closed conditions: unknown activity is never silently classified as Dormant — it is preserved as ActivityUnknown so orphan detection (§11) does not act on a missing-data assumption. Owner resolution failures are preserved as OwnerStatus='OwnerNotFound' with the original ObjectId, distinguishing a legitimately absent owner from a transient Graph error.


§11 — Orphan and risk detection (Find-Agt31OrphanedAgent)

Why this section exists. Departed-owner agents are the highest-likelihood SOX 404 / FINRA 4511 finding because the audit trail has nobody to attest the agent's purpose, scope, or last review. This leg classifies every enriched record into orphan categories and recommends a lifecycle action.

# scripts/Find-Agt31OrphanedAgent.ps1
function Find-Agt31OrphanedAgent {
<#
.SYNOPSIS
    Classifies every enriched agent into orphan / dormant / unowned categories with a recommended lifecycle action.
.DESCRIPTION
    Categories (mutually non-exclusive — an agent may have several flags):
      - DepartedOwner: OwnerStatus is Disabled or OwnerNotFound
      - DormantSharedAgent: Zone in (Zone2, Zone3) AND ActivityStatus = Dormant
      - UnknownActivity: ActivityStatus = ActivityUnknown (cannot be acted on without manual review)
      - HighRiskMcp: Plane = MCP and RiskFlag = High
      - DisplayNameConflict: ConflictNotes has any entry
.PARAMETER Enriched
.OUTPUTS
    [pscustomobject[]]
.EXAMPLE
    PS> $orphans = Find-Agt31OrphanedAgent -Enriched $enriched | Where-Object Flags
.NOTES
    Recommended actions are advisory; final lifecycle transitions go through Set-Agt31LifecycleState (§12)
    which is the only function in this playbook that mutates state.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param([Parameter(Mandatory)] [pscustomobject[]]$Enriched)
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    foreach ($r in $Enriched) {
        $flags = New-Object System.Collections.Generic.List[string]
        if ($r.OwnerStatus -in 'Disabled','OwnerNotFound') { $flags.Add('DepartedOwner') }
        if ($r.Zone -in 'Zone2','Zone3' -and $r.ActivityStatus -eq 'Dormant') { $flags.Add('DormantSharedAgent') }
        if ($r.ActivityStatus -eq 'ActivityUnknown') { $flags.Add('UnknownActivity') }
        $mcpSrc = $r.SourceRecords | Where-Object { $_.Plane -eq 'MCP' -and $_.RiskFlag -eq 'High' } | Select-Object -First 1
        if ($mcpSrc) { $flags.Add('HighRiskMcp') }
        if ($r.ConflictNotes -and $r.ConflictNotes.Count -gt 0) { $flags.Add('DisplayNameConflict') }

        $action = 'Review'
        if ($flags -contains 'DepartedOwner' -and $flags -contains 'DormantSharedAgent') { $action = 'RecommendDecommission' }
        elseif ($flags -contains 'DepartedOwner') { $action = 'RecommendOwnerTransfer' }
        elseif ($flags -contains 'DormantSharedAgent') { $action = 'RecommendDeprecate' }
        elseif ($flags -contains 'HighRiskMcp') { $action = 'EscalateToControl1.7' }
        elseif ($flags.Count -eq 0) { $action = 'NoAction' }

        Add-Member -InputObject $r -NotePropertyName Flags -NotePropertyValue ($flags.ToArray()) -Force
        Add-Member -InputObject $r -NotePropertyName RecommendedAction -NotePropertyValue $action -Force
    }
    return $Enriched
}

Fail-closed conditions: UnknownActivity is a flag (not a recommendation to decommission). Decommission recommendations are issued only when both DepartedOwner and DormantSharedAgent are present — never on activity alone, because activity-source gaps (§5 report unavailable) must not produce mass decommission proposals.


§12 — Lifecycle state machine (Set-Agt31LifecycleState)

Why this section exists. The parent control defines a documented state machine (Draft → InReview → Active → Deprecated → Decommissioned, with optional Suspended branch). Every transition requires a Purview-auditable reason and an authorized transitioner. This is the only function in this playbook that mutates tenant state.

# scripts/Set-Agt31LifecycleState.ps1
function Set-Agt31LifecycleState {
<#
.SYNOPSIS
    Records a documented lifecycle transition for a canonical agent against the inventory store.
.DESCRIPTION
    Applies a transition (e.g., Active -> Deprecated) to the SharePoint or Dataverse inventory store
    of record. Validates that the requested transition is allowed by the published state machine.
    Writes a Purview audit-log entry via the Compliance Portal-aware audit endpoint. Idempotent: a
    request to set the current state is logged but not re-applied.
.PARAMETER CanonicalAgentId
.PARAMETER NewState
.PARAMETER Reason
    Required free-text. Captured to the audit trail; minimum 20 characters.
.PARAMETER Approver
    UPN of the authorized approver (must be in the Inventory Owners directory role).
.PARAMETER InventoryStore
    URI to the SharePoint list or Dataverse table acting as the system of record.
.OUTPUTS
    [pscustomobject]
.EXAMPLE
    PS> Set-Agt31LifecycleState -CanonicalAgentId 'agt-...' -NewState Decommissioned -Reason 'Owner departed; no successor identified within 30d window' -Approver 'governance.lead@contoso.com' -InventoryStore $listUrl -WhatIf
.NOTES
    -WhatIf is honored. Mutation runs through SupportsShouldProcess. The function refuses to act on
    -records whose ConflictNotes are non-empty unless -ForceConflicted is specified, because acting
    on conflicting data is the most common cause of inventory data quality regressions.
#>
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$CanonicalAgentId,
        [Parameter(Mandatory)] [ValidateSet('Draft','InReview','Active','Suspended','Deprecated','Decommissioned')] [string]$NewState,
        [Parameter(Mandatory)] [ValidateLength(20,2000)] [string]$Reason,
        [Parameter(Mandatory)] [string]$Approver,
        [Parameter(Mandatory)] [uri]$InventoryStore,
        [Parameter()] [switch]$ForceConflicted
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $allowed = @{
        'Draft'         = @('InReview','Decommissioned')
        'InReview'      = @('Active','Draft','Decommissioned')
        'Active'        = @('Suspended','Deprecated','Decommissioned')
        'Suspended'     = @('Active','Deprecated','Decommissioned')
        'Deprecated'    = @('Decommissioned','Active')   # reactivation requires re-attestation
        'Decommissioned'= @()                            # terminal
    }

    # Read current item from inventory store (SharePoint REST shown; Dataverse path mirrors the same shape)
    $currentItem = Invoke-PnPSPRestMethod -Url ("{0}/items?`$filter=fields/CanonicalAgentId eq '{1}'" -f $InventoryStore, $CanonicalAgentId) -Method Get
    if (-not $currentItem.value -or $currentItem.value.Count -eq 0) {
        throw "CanonicalAgentId $CanonicalAgentId not found in inventory store $InventoryStore"
    }
    $item = $currentItem.value[0]
    $current = $item.fields.LifecycleState
    $conflicts = $item.fields.ConflictNotes

    if ($conflicts -and -not $ForceConflicted) {
        throw "Refusing to transition $CanonicalAgentId because ConflictNotes is non-empty: '$conflicts'. Resolve the conflict in the inventory store first or pass -ForceConflicted with documented justification."
    }

    if ($current -eq $NewState) {
        Write-Verbose "Idempotent no-op: $CanonicalAgentId already in $NewState"
        return [pscustomobject]@{ CanonicalAgentId=$CanonicalAgentId; PreviousState=$current; NewState=$NewState; Action='NoOpIdempotent'; At=(Get-Date).ToUniversalTime() }
    }

    if ($allowed[$current] -notcontains $NewState) {
        throw "Illegal transition: $current -> $NewState. Allowed from $current: $($allowed[$current] -join ', ')"
    }

    if ($PSCmdlet.ShouldProcess("Inventory item $($item.id) ($CanonicalAgentId)", "Transition $current -> $NewState")) {
        Invoke-PnPSPRestMethod -Url ("{0}/items/{1}/fields" -f $InventoryStore, $item.id) -Method Patch -Content @{
            LifecycleState   = $NewState
            LastTransitionAt = (Get-Date).ToString('o')
            LastTransitionBy = $Approver
            LastTransitionReason = $Reason
        }

        # Purview audit emission (custom event). Falls back to Write-EventLog on Win for incident replay.
        try {
            Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/security/auditLog/customEvents" -Body @{
                source='Control3.1'; eventType='AgentLifecycleTransition'
                payload=@{ agentId=$CanonicalAgentId; from=$current; to=$NewState; reason=$Reason; approver=$Approver }
            }
        } catch {
            Write-Warning "Custom audit emission failed: $($_.Exception.Message). Local Windows event log fallback engaged."
            if ($IsWindows) {
                Write-EventLog -LogName 'Application' -Source 'Agt31' -EventId 3100 -EntryType Information -Message ("Agent {0} {1}->{2} by {3}: {4}" -f $CanonicalAgentId,$current,$NewState,$Approver,$Reason)
            }
        }
    }

    [pscustomobject]@{ CanonicalAgentId=$CanonicalAgentId; PreviousState=$current; NewState=$NewState; Action='Applied'; Approver=$Approver; Reason=$Reason; At=(Get-Date).ToUniversalTime() }
}

Fail-closed conditions: illegal transitions throw; conflicted records require explicit -ForceConflicted; Purview emission failure falls back to Windows event log (never silently dropped); -WhatIf is honored end-to-end.


§13 — Evidence pack (Export-Agt31EvidencePack)

Why this section exists. Examiners (FINRA / SEC / OCC / state) request reproducible artifacts. A run that cannot be replayed and re-hashed to produce identical inputs is not durable evidence. This leg writes a run-id-stamped folder containing CSV + JSONL + Excel projections of the inventory, a reconcile-errors.csv of every per-leg failure, a manifest.json listing every file with SHA-256 hash and module/CLI versions, and a code-signed copy of manifest.json to bind the manifest to the inventory owner identity.

# scripts/Export-Agt31EvidencePack.ps1
function Export-Agt31EvidencePack {
<#
.SYNOPSIS
    Writes a reproducible evidence pack for the run, with SHA-256 hashes and a signed manifest.
.DESCRIPTION
    Outputs to {RootFolder}/{yyyyMMdd-HHmm}-{RunId}/:
      - canonical-inventory.csv  (one row per CanonicalAgentId, enriched + flagged)
      - canonical-inventory.jsonl (full record incl. SourceRecords for forensic replay)
      - canonical-inventory.xlsx (filtered view by Zone, RecommendedAction)
      - reconcile-errors.csv     (per-leg failures from §9)
      - per-leg/cs.json, da.json, hub.json, reg.json, mcp.json, spo.json (raw leg outputs)
      - manifest.json            (file paths, SHA-256, module versions, sovereign profile, run id)
      - manifest.json.p7s        (Authenticode-signed manifest)
.PARAMETER Session
.PARAMETER LegResults
.PARAMETER FlaggedInventory
    Output of Find-Agt31OrphanedAgent.
.PARAMETER RootFolder
    Parent folder; the function creates a per-run subfolder.
.PARAMETER SigningCertThumbprint
    Code-signing certificate for manifest.json. Must chain to your enterprise CA.
.OUTPUTS
    [pscustomobject] with EvidenceFolder, ManifestPath, SignedManifestPath, FileCount, TotalBytes.
.EXAMPLE
    PS> $pack = Export-Agt31EvidencePack -Session $session -LegResults $legs -FlaggedInventory $flagged -RootFolder 'D:\agt31-evidence' -SigningCertThumbprint $thumb
.NOTES
    Write the RootFolder to a WORM-protected location (Azure Storage immutable blob, SharePoint Records
    Center, or on-prem WORM share). The hash + signature only proves integrity from creation forward;
    immutability of the medium is the second leg of the evidence chain.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [pscustomobject]$Session,
        [Parameter(Mandatory)] [hashtable]$LegResults,
        [Parameter(Mandatory)] [pscustomobject[]]$FlaggedInventory,
        [Parameter(Mandatory)] [string]$RootFolder,
        [Parameter(Mandatory)] [string]$SigningCertThumbprint
    )
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmm')
    $runFolder = Join-Path $RootFolder ("{0}-{1}" -f $stamp, $Session.RunId)
    New-Item -ItemType Directory -Path $runFolder -Force | Out-Null
    $perLegFolder = Join-Path $runFolder 'per-leg'
    New-Item -ItemType Directory -Path $perLegFolder -Force | Out-Null

    # Canonical projections
    $csvPath  = Join-Path $runFolder 'canonical-inventory.csv'
    $jsonl    = Join-Path $runFolder 'canonical-inventory.jsonl'
    $xlsxPath = Join-Path $runFolder 'canonical-inventory.xlsx'

    $FlaggedInventory |
        Select-Object CanonicalAgentId, DisplayName, Zone, OwnerUpn, OwnerStatus, ManagerUpn,
            LastActivityDate, ActivityStatus, ActivityCount90d, Sources, Flags, RecommendedAction,
            EnvironmentId, AppId, ObjectId, BotId, EnrichedAt |
        Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8

    $FlaggedInventory | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Out-File -FilePath $jsonl -Encoding UTF8

    # XLSX: requires ImportExcel module if available; otherwise skip with a recorded note
    $xlsxNote = $null
    if (Get-Module -ListAvailable -Name ImportExcel) {
        Import-Module ImportExcel -Force
        $FlaggedInventory | Export-Excel -Path $xlsxPath -WorksheetName 'Inventory' -AutoSize -FreezeTopRow -BoldTopRow
    } else {
        $xlsxNote = 'ImportExcel module not present; XLSX projection skipped'
        Set-Content -Path "$xlsxPath.skipped.txt" -Value $xlsxNote -Encoding UTF8
    }

    # Per-leg raw payloads
    foreach ($legName in $LegResults.Keys) {
        $LegResults[$legName] | ConvertTo-Json -Depth 8 | Out-File -FilePath (Join-Path $perLegFolder "$legName.json") -Encoding UTF8
    }

    # Reconcile errors
    $errPath = Join-Path $runFolder 'reconcile-errors.csv'
    if ($script:Agt31LastReconcileErrors) {
        $script:Agt31LastReconcileErrors | Export-Csv -Path $errPath -NoTypeInformation -Encoding UTF8
    } else {
        '"Plane","Status","Reason","Detail"' | Out-File -FilePath $errPath -Encoding UTF8
    }

    # Manifest
    $files = Get-ChildItem -Path $runFolder -Recurse -File
    $manifest = [pscustomobject]@{
        ControlId        = '3.1'
        RunId            = $Session.RunId
        TenantId         = $Session.TenantId
        SovereignCloud   = $Session.Profile.Cloud
        AgentRegistryStatus = $Session.Profile.AgentRegistryStatus
        GeneratedAtUtc   = (Get-Date).ToUniversalTime()
        Generator        = "Agt31 PowerShell Setup playbook (April 2026, v1.4)"
        PowerShellEdition= "$($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)"
        ModuleVersions   = (Get-Module | Select-Object Name, Version)
        FileCount        = $files.Count
        Files            = $files | ForEach-Object {
            [pscustomobject]@{
                RelativePath = $_.FullName.Substring($runFolder.Length+1)
                SizeBytes    = $_.Length
                Sha256       = (Get-FileHash -Algorithm SHA256 -Path $_.FullName).Hash
            }
        }
        Notes = $xlsxNote
    }
    $manifestPath = Join-Path $runFolder 'manifest.json'
    $manifest | ConvertTo-Json -Depth 10 | Out-File -FilePath $manifestPath -Encoding UTF8

    # Sign the manifest
    $cert = Get-Item -Path "Cert:\CurrentUser\My\$SigningCertThumbprint" -ErrorAction Stop
    $sig = Set-AuthenticodeSignature -FilePath $manifestPath -Certificate $cert -HashAlgorithm SHA256 -TimestampServer 'http://timestamp.digicert.com'
    if ($sig.Status -ne 'Valid') {
        throw "Manifest signing failed: $($sig.Status)$($sig.StatusMessage). Evidence pack will not be released without a valid signature."
    }

    [pscustomobject]@{
        EvidenceFolder    = $runFolder
        ManifestPath      = $manifestPath
        SignatureStatus   = $sig.Status
        FileCount         = $files.Count
        TotalBytes        = ($files | Measure-Object -Property Length -Sum).Sum
        ReconcileErrorCount = ($script:Agt31LastReconcileErrors)?.Count ?? 0
    }
}

Fail-closed conditions: manifest signing failure throws — evidence is not released. Reconcile-errors.csv is always written, even when empty (the empty-with-header file is itself evidence that the reconciler ran clean). xlsxNote records the absence of ImportExcel rather than silently skipping.


§14 — End-to-end validation, anti-patterns, and operating cadence

14.1 Test-Agt31Implementation

# scripts/Test-Agt31Implementation.ps1
function Test-Agt31Implementation {
<#
.SYNOPSIS
    Smoke-tests the entire Control 3.1 pipeline against a tenant and emits a pass/fail report.
.DESCRIPTION
    Runs the orchestrator end-to-end with -WhatIf where applicable, then verifies:
      - Every leg returned at least one record (OK or status row).
      - Reconciler produced at least one canonical record (or, in a green-field tenant, a documented zero with manifest note).
      - Evidence pack manifest exists, is signed, and the signature is Valid.
      - At least one Zone1 / Zone2 / Zone3 record exists in tenants where each zone is in use (warn-only).
      - Reconcile-errors count matches the number of non-OK leg rows.
.PARAMETER Session
.OUTPUTS
    [pscustomobject[]] of test result rows.
.EXAMPLE
    PS> Test-Agt31Implementation -Session $session
.NOTES
    Run nightly under a CI agent. Failing rows post to the Control 3.1 owner via the standard
    AI Governance ServiceNow connector.
#>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param([Parameter(Mandatory)] [pscustomobject]$Session)
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Continue'

    $results = New-Object System.Collections.Generic.List[pscustomobject]
    function _addResult($name,$pass,$detail) {
        $results.Add([pscustomobject]@{ TestName=$name; Pass=$pass; Detail=$detail; At=(Get-Date).ToUniversalTime() })
    }

    try {
        $cs  = Get-Agt31CopilotStudioInventory -Session $Session -ExchangeFolder (Join-Path $env:TEMP 'agt31-test')
        _addResult 'Leg:CopilotStudio' ($cs.Count -gt 0) "Records=$($cs.Count)"
    } catch { _addResult 'Leg:CopilotStudio' $false $_.Exception.Message }

    try {
        $da = Get-Agt31DeclarativeAgentInventory -Session $Session
        _addResult 'Leg:DeclarativeAgent' ($da.Count -gt 0) "Records=$($da.Count)"
    } catch { _addResult 'Leg:DeclarativeAgent' $false $_.Exception.Message }

    try {
        $hub = Get-Agt31CopilotHubInventory -Session $Session -WindowDays 7
        _addResult 'Leg:CopilotHub' ($hub.Count -gt 0) "Records=$($hub.Count)"
    } catch { _addResult 'Leg:CopilotHub' $false $_.Exception.Message }

    try {
        $reg = Get-Agt31AgentRegistryInventory -Session $Session
        _addResult 'Leg:AgentRegistry' ($reg.Count -gt 0) "Records=$($reg.Count); SovereignStatus=$($Session.Profile.AgentRegistryStatus)"
    } catch { _addResult 'Leg:AgentRegistry' $false $_.Exception.Message }

    try {
        $mcp = Get-Agt31McpServerInventory -Session $Session
        _addResult 'Leg:MCP' ($mcp.Count -ge 0) "Records=$($mcp.Count)"
    } catch { _addResult 'Leg:MCP' $false $_.Exception.Message }

    try {
        $spo = Get-Agt31SharePointAgentInventory -Session $Session -MaxSites 50
        _addResult 'Leg:SharePointAgent' ($spo.Count -ge 0) "Records=$($spo.Count)"
    } catch { _addResult 'Leg:SharePointAgent' $false $_.Exception.Message }

    try {
        $merged = Merge-Agt31Inventory -LegResults @{
            CopilotStudio=$cs; DeclarativeAgent=$da; CopilotHub=$hub;
            AgentRegistry=$reg; MCP=$mcp; SharePointAgent=$spo
        }
        _addResult 'Reconcile:Merge' ($merged.Count -ge 0) "Canonical=$($merged.Count); Errors=$(($script:Agt31LastReconcileErrors)?.Count ?? 0)"
    } catch { _addResult 'Reconcile:Merge' $false $_.Exception.Message }

    return $results.ToArray()
}

14.2 Anti-patterns (do not ship code that does any of these)

# Anti-pattern Why it is wrong Correct approach
1 Single-leg inventory script with Get-AdminPowerApp only Misses declarative / MCP / SPO / Hub agents — false-clean Multi-leg reconciler in §3–§9
2 Running orchestrator under powershell.exe (5.1) Graph v2 / PnP v2 / ExO v3 don't load; silent autoload of v1 produces wrong shapes pwsh 7.4 orchestrator; spawn 5.1 child only for Power Apps Admin (§3)
3 Add-PowerAppsAccount without -Endpoint on a sovereign tenant Returns zero environments silently §2 hard-fail on sovereign mismatch
4 += @($r) to accumulate records O(n²) on 10k-agent tenants [List[pscustomobject]]::new() accumulator
5 Default LastActivity to Get-Date when source missing Corrupts orphan classification §10 ActivityUnknown preserved; never silently filled
6 Owner string-match (*system*, *deleted*) Misses real orphans + false positives §11 resolve ObjectId via Get-MgUser and inspect AccountEnabled
7 Inventory CSV overwritten in place each run Destroys reconciliation history §13 run-id-stamped subfolder under WORM root
8 SHA-256 hash file with no signature Hashes can be regenerated by anyone with file access §13 Set-AuthenticodeSignature on manifest
9 Treating Search-UnifiedAuditLog first page as authoritative Missing rows misclassify activity Paginate with SessionId/SessionCommand ReturnLargeSet
10 Catching exceptions and continuing without recording Silent partial coverage Every leg writes a Status != OK row when it cannot enumerate
11 Decommissioning purely on Dormant signal Acts on missing-data assumption when activity source was unavailable §11 require both DepartedOwner + DormantSharedAgent before recommending decommission
12 Using one service principal for read + write Breaks SOX 404 separation of duties §1 separate agt31-inventory-reader and agt31-pp-reader from any write principal
13 Hard-coding the Copilot Studio appType discriminator Microsoft churns the value across releases §3 enumerate all values, mark drift in Notes
14 Skipping the SharePoint plane "because Copilot Studio handles it" SPO-grounded agents do not appear in PA admin §8 SPO + Graph beta site agents
15 Trusting value.Count without paging Page 1 only — under-counts large tenants §6 paging follow on @odata.nextLink

14.3 Operating cadence

Frequency Action Output destination
Every 4 hours Test-Agt31Implementation smoke test Operational dashboard (Control 3.8)
Daily 02:00 tenant TZ Full inventory + enrichment + evidence pack WORM evidence store; reconcile-errors row count to Control 3.11
Weekly Orphan + DormantSharedAgent review with business owner Recommended actions land in Control 3.6 review queue
Monthly Module / CLI version pin review against CAB-approved baseline CAB ticket + Test-Agt31Tooling proof
Quarterly Sovereign endpoint parity re-verification (§1 matrix) Updated matrix in this playbook + manifest metadata
On Microsoft release notes change Re-verify appType discriminator, Graph endpoint paths, Agent Registry GA status Issue against the parent control spec

Cross-references


Updated: April 2026 | Version: v1.4.0 | Maintained by: AI Governance Team