Control 3.6 — PowerShell Setup: Orphaned Agent Detection and Remediation
Scope. This playbook automates the five primary detection signal sources, the four-tier remediation ladder, the cross-surface reconciliation engine, the bulk-reassignment safety gates, the sovereign-cloud manual reconciliation worksheet, and the SIEM forwarding pipeline defined in Control 3.6 — Orphaned Agent Detection and Remediation.
Baseline. All scripts assume the conventions in _shared/powershell-baseline.md. Sovereign-cloud endpoints are documented in §3 — Sovereign Cloud Endpoints (GCC, GCC High, DoD).
Namespace. All functions in this playbook use the
Agt36prefix to prevent collision with peer-control automation (Agt225,Agt226,Agt12).Hedged-language reminder. Detection output supports compliance with FINRA Rule 3110 supervisory review, SEC 17a-4(f) records retention, and SOX ITGC ownership integrity — it does not replace registered-principal supervisory review or the human approval gates required at each tier of the remediation ladder.
§0 — Wrong-shell trap and false-clean defects
Control 3.6 false negatives almost always stem from running the wrong shell, the wrong module build, or a filter that silently excludes an entire signal surface. Treat every "clean" report from a new workstation as suspect until the self-test in §13 passes.
0.1 — Wrong-shell trap
- Windows PowerShell 5.1 is not supported.
Microsoft.Graphv2.19+ requires PowerShell 7.4+. Running under 5.1 installs the v1.x legacy module path and returns partialServicePrincipalpayloads without thetagscollection — signal #1 (ownerless Entra Agent ID) will silently return zero rows. - Integrated Script Environment (ISE) is not supported for interactive sovereign-cloud bootstrap — device-code flow UI is clipped. Use Windows Terminal +
pwsh.exe. - Azure Cloud Shell has no Power Platform module. Signals #3 and #4 will fail to load
Microsoft.PowerApps.Administration.PowerShell. Run this playbook from a privileged admin workstation, not Cloud Shell.
# Enforce before sourcing any Agt36 function
if ($PSVersionTable.PSVersion.Major -lt 7 -or $PSVersionTable.PSVersion.Minor -lt 4) {
throw "Agt36 requires PowerShell 7.4 or later. Current: $($PSVersionTable.PSVersion)"
}
if ($Host.Name -eq 'Windows PowerShell ISE Host') {
throw "Agt36 is not supported under Windows PowerShell ISE. Use Windows Terminal with pwsh.exe."
}
0.2 — False-clean defects (Agt36-specific)
| # | Defect | Symptom | Root cause | Fix |
|---|---|---|---|---|
| 1 | Empty Power Platform environment list | Signal #3/#4 return zero rows tenant-wide | Get-AdminPowerAppEnvironment called before Add-PowerAppsAccount completed cached-token refresh |
Call Test-Agt36PowerPlatformSession before detection; assert .Count -gt 0 |
| 2 | Missing tags/any(..) filter |
Signal #1 returns all service principals, not just agents | Using Get-MgServicePrincipal -All without OData filter |
Use Get-MgServicePrincipal -Filter "tags/any(t:t eq 'AgentIdentity')" -ConsistencyLevel eventual -CountVariable count |
| 3 | HR connector stale > 24h | Signal #2 (sponsor-departed) returns zero rows during live termination | employeeLeaveDateTime attribute not synced from HR source of truth |
Test-Agt36HRFreshness gate — abort if max(lastSyncDateTime) > 24h |
| 4 | Deleted environment ghost | Signal #4 reports "env owner departed" for already-deleted envs | Soft-deleted envs linger 7 days in Get-AdminPowerAppEnvironment -Filter "Deleted" |
Exclude properties.provisioningState -eq 'Deleted' before join |
| 5 | Cross-surface duplicate explosion | One agent appears 5x in orphan register | Reconciliation engine keys on per-surface ID instead of canonical AgentId |
Canonicalize on 1.2 registry AgentId before Merge-Agt36OrphanRegister |
| 6 | Grace-window race | Agent marked orphan the instant sponsor termed, before HR grace window | Detection runs at T+0 but SLA clock starts at T+24h per zone | Apply Get-Agt36GraceWindow -Zone -SignalSource before SLA aging |
| 7 | Sovereign silent skew | GCC High report is clean while commercial shows 40 orphans | Sovereign cloud has no Graph parity for HR employeeLeaveDateTime |
Route sovereign tenants through §8 manual reconciliation worksheet — do not early-exit to "clean" |
| 8 | Throttled paging truncation | Signal #1 returns exactly 999 rows tenant-wide | @odata.nextLink not followed after HTTP 429 backoff |
Use Invoke-Agt36GraphPaged with retry-after honoring |
| 9 | Card-vs-detection drift | Admin Center card (3.13) shows 12 orphans, detection reports 8 | Detection filter excludes disabled-but-unlicensed sponsors | Align filter with 3.13 definitions (cross-check §12 RECONCILE Pester) |
| 10 | SharePoint author disabled-user mask | Signal #5 misses Copilot Studio agents authored by disabled users | PnP.PowerShell Get-PnPUser caches resolved principals for 8h |
Force -Refresh or bypass cache via Graph directoryObjects/getByIds |
0.3 — Self-test before every production run
Every scheduled detection job must invoke Invoke-Agt36SelfTest (§13) before emitting results to SIEM. A failed self-test emits an operational alert to the AI Governance Lead and suppresses the detection batch until resolved — it does not emit a "clean" report.
§1 — Module pinning, Graph scopes, and canonical roles
1.1 — Module version matrix
Pin exact minimum versions. Later minor versions are acceptable, but do not float to an unpinned -MinimumVersion — sovereign-cloud tenants have observed Graph SDK v2.21 regressions on tags OData serialization.
$Agt36ModuleMatrix = @(
@{ Name = 'Microsoft.Graph.Authentication'; Min = '2.19.0' },
@{ Name = 'Microsoft.Graph.Applications'; Min = '2.19.0' },
@{ Name = 'Microsoft.Graph.Users'; Min = '2.19.0' },
@{ Name = 'Microsoft.Graph.Identity.DirectoryManagement'; Min = '2.19.0' },
@{ Name = 'Microsoft.Graph.Identity.Governance'; Min = '2.19.0' },
@{ Name = 'Microsoft.PowerApps.Administration.PowerShell'; Min = '2.0.175' },
@{ Name = 'MicrosoftTeams'; Min = '6.1.0' },
@{ Name = 'PnP.PowerShell'; Min = '2.4.0' }
)
function Test-Agt36ModuleMatrix {
[CmdletBinding()]
param([switch]$InstallMissing)
$results = foreach ($m in $Agt36ModuleMatrix) {
$installed = Get-Module -ListAvailable -Name $m.Name |
Sort-Object Version -Descending | Select-Object -First 1
$status = if (-not $installed) { 'Missing' }
elseif ($installed.Version -lt [version]$m.Min) { 'Outdated' }
else { 'OK' }
if ($status -ne 'OK' -and $InstallMissing) {
Install-Module $m.Name -MinimumVersion $m.Min -Scope CurrentUser -Force -AllowClobber
$status = 'Installed'
}
[pscustomobject]@{
Module = $m.Name; Required = $m.Min;
Installed = $installed.Version; Status = $status
}
}
$results
if ($results.Status -contains 'Missing' -or $results.Status -contains 'Outdated') {
throw "Agt36 module matrix failed. Re-run with -InstallMissing."
}
}
1.2 — Microsoft Graph scope matrix
Two least-privilege profiles are defined — read-only detection and mutation (remediation). They are never combined into a single token. Mutation operations require step-up via PIM activation (§1.4).
| Operation | Graph scopes (delegated) | Graph scopes (app-only) |
|---|---|---|
| Detection (ReadOnly) | Application.Read.All, Directory.Read.All, User.Read.All, AuditLog.Read.All |
Application.Read.All, Directory.Read.All, User.Read.All, AuditLog.Read.All |
| Remediation (Mutation) | Application.ReadWrite.All, Directory.ReadWrite.All, User.ReadWrite.All, RoleManagement.ReadWrite.Directory |
Application.ReadWrite.OwnedBy (preferred), fall back to Application.ReadWrite.All only with documented scope-creep justification |
| Sponsor manager transfer | User.ReadWrite.All, Directory.ReadWrite.All |
Same |
$Agt36Scopes = @{
Detect = @('Application.Read.All','Directory.Read.All','User.Read.All','AuditLog.Read.All')
Remediate = @('Application.ReadWrite.All','Directory.ReadWrite.All','User.ReadWrite.All','RoleManagement.ReadWrite.Directory')
}
1.3 — Canonical roles (align with role-catalog)
| Function | Canonical role(s) | PIM activation window | Notes |
|---|---|---|---|
| Detection read | Entra Global Reader | 8h | Read-only scopes only |
| Agent SP remediation | Entra Agent ID Admin | 4h | Tier-1 owner inline edit |
| Sponsor transfer | Entra Identity Governance Admin | 4h | Signal #2 override path |
| Power Platform reassignment | Power Platform Admin | 4h | Tier-2/3 mutation |
| Terminal disposal handoff | AI Administrator | 2h | Tier-4 only; hands off to 2.25 |
| Evidence labeling | Purview Compliance Admin | 8h | 6-year WORM retention apply |
| Governance sign-off | AI Governance Lead | N/A (standing) | Attestation only |
All role activations must reference a ChangeTicketId in the PIM justification when used for mutation. Detection-only activations reference the scheduled job run-id.
1.4 — PIM activation helper
function Request-Agt36PimActivation {
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('Detect','Remediate','SponsorTransfer','PowerPlatform','Terminal','Evidence')]
[string]$Profile,
[Parameter(Mandatory)][string]$Justification,
[string]$ChangeTicketId,
[ValidateRange(1,8)][int]$DurationHours = 4
)
if ($Profile -ne 'Detect' -and -not $ChangeTicketId) {
throw "ChangeTicketId is required for mutation profiles. Detect is the only profile exempt."
}
# NOTE: interactive wrapper; production flows call the Graph Identity Governance
# /roleManagement/directory/roleAssignmentScheduleRequests endpoint with
# action=selfActivate and a structured justification payload.
[pscustomobject]@{
Profile = $Profile
ChangeTicketId = $ChangeTicketId
Justification = $Justification
DurationHours = $DurationHours
RequestedAtUtc = (Get-Date).ToUniversalTime()
Status = 'Pending' # updated by Graph response in production wrapper
}
}
§2 — Sovereign-cloud bootstrap (GCC, GCC High, DoD)
Critical difference vs. 2.26. Control 3.6 does not early-exit on sovereign clouds. Sovereign tenants must still produce a reconciliation artifact — the compensating control is a quarterly manual reconciliation worksheet (§8) joining HR leaver lists, Entra disabled-user exports, Power Platform maker/env-owner exports, and the 1.2 agent inventory registry.
function Initialize-Agt36SovereignContext {
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('Commercial','USGov','USGovHigh','USGovDoD')]
[string]$Cloud
)
$endpoints = switch ($Cloud) {
'Commercial' { @{ Graph='https://graph.microsoft.com'; AzureAD='https://login.microsoftonline.com'; PP='prod'; PnPEnv='Production' } }
'USGov' { @{ Graph='https://graph.microsoft.us'; AzureAD='https://login.microsoftonline.us'; PP='usgov'; PnPEnv='USGovernment' } }
'USGovHigh' { @{ Graph='https://graph.microsoft.us'; AzureAD='https://login.microsoftonline.us'; PP='usgovhigh'; PnPEnv='USGovernmentHigh' } }
'USGovDoD' { @{ Graph='https://dod-graph.microsoft.us'; AzureAD='https://login.microsoftonline.us'; PP='dod'; PnPEnv='USGovernmentDoD' } }
}
$ctx = [pscustomobject]@{
Cloud = $Cloud
GraphEndpoint = $endpoints.Graph
AzureADEndpoint = $endpoints.AzureAD
PowerPlatformEnv = $endpoints.PP
PnPEnvironment = $endpoints.PnPEnv
RequiresManualRecon = $Cloud -ne 'Commercial'
HRConnectorParity = $Cloud -eq 'Commercial' # employeeLeaveDateTime parity
InitializedAtUtc = (Get-Date).ToUniversalTime()
}
if ($ctx.RequiresManualRecon) {
Write-Warning "Sovereign cloud '$Cloud' detected. Signal #2 (sponsor-departed via employeeLeaveDateTime) has no Graph parity. Route to Export-Agt36SovereignReconciliationWorksheet (§8). DO NOT treat detection output as authoritative."
}
$ctx
}
The returned context object is threaded through every detection function so that sovereign-specific branching (manual reconciliation fallback for signal #2, reduced HR freshness gates) is an explicit parameter, never a global state flag.
§3 — Connection helper (Initialize-Agt36Session)
A single entry point connects to every surface needed for cross-signal detection. Failures are never swallowed — a surface that cannot connect returns a session object with Status = 'Error' for that surface, and downstream detection functions emit Status = 'NotApplicable' for their signal rather than falsely reporting Clean.
function Initialize-Agt36Session {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$TenantId,
[Parameter(Mandatory)][ValidateSet('Detect','Remediate','SponsorTransfer','PowerPlatform','Terminal','Evidence')]
[string]$Profile,
[Parameter(Mandatory)][ValidateSet('Commercial','USGov','USGovHigh','USGovDoD')]
[string]$Cloud,
[string]$ChangeTicketId
)
$sovereign = Initialize-Agt36SovereignContext -Cloud $Cloud
$scopes = if ($Profile -eq 'Detect') { $Agt36Scopes.Detect } else { $Agt36Scopes.Remediate }
$session = [pscustomobject]@{
TenantId = $TenantId
Profile = $Profile
Sovereign = $sovereign
ChangeTicketId = $ChangeTicketId
ConnectedAtUtc = (Get-Date).ToUniversalTime()
GraphStatus = 'Pending'
PowerPlatformStatus = 'Pending'
TeamsStatus = 'Pending'
PnPStatus = 'Pending'
Errors = @()
}
# Graph
try {
Connect-MgGraph -TenantId $TenantId -Scopes $scopes -Environment (
switch ($Cloud) {
'Commercial'{'Global'} 'USGov'{'USGov'} 'USGovHigh'{'USGovHigh'} 'USGovDoD'{'USGovDoD'}
}) -NoWelcome -ErrorAction Stop
$session.GraphStatus = 'Connected'
} catch { $session.GraphStatus = 'Error'; $session.Errors += "Graph: $($_.Exception.Message)" }
# Power Platform
try {
Add-PowerAppsAccount -Endpoint $sovereign.PowerPlatformEnv -ErrorAction Stop | Out-Null
$session.PowerPlatformStatus = 'Connected'
} catch { $session.PowerPlatformStatus = 'Error'; $session.Errors += "PowerPlatform: $($_.Exception.Message)" }
# Teams (signal #5 cross-reference; optional for detect-only)
try {
Connect-MicrosoftTeams -TenantId $TenantId -ErrorAction Stop | Out-Null
$session.TeamsStatus = 'Connected'
} catch { $session.TeamsStatus = 'Error'; $session.Errors += "Teams: $($_.Exception.Message)" }
# PnP (signal #5 — SharePoint-hosted Copilot Studio authors)
# Connection per-site is deferred to Get-Agt36SharePointAuthorDisabledAgent.
$session.PnPStatus = 'Deferred'
if ($Profile -eq 'Detect' -and $session.GraphStatus -ne 'Connected') {
throw "Agt36 Detect profile requires Graph. Errors: $($session.Errors -join '; ')"
}
$session
}
§4 — Detection functions (Signals 1–5)
Every detection helper returns a structured object with Status ∈ {Clean, Anomaly, Pending, NotApplicable, Error}. Returning $null, an empty array without a status wrapper, or an untyped hashtable is considered a bug — see §0.2 defect #1 and §13 Pester DETECT namespace.
4.1 — Signal #1: Ownerless Entra Agent ID service principals
function Get-Agt36OwnerlessAgent {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][ValidateSet(1,2,3)][int]$Zone,
[int]$GraceHours = 24
)
if ($Session.GraphStatus -ne 'Connected') {
return [pscustomobject]@{ SignalId=1; Status='NotApplicable'; Reason='Graph not connected'; Findings=@() }
}
try {
# Page through all agent service principals; filter for zero owners
$agents = Invoke-Agt36GraphPaged -Uri "/v1.0/servicePrincipals?`$filter=tags/any(t:t eq 'AgentIdentity')&`$select=id,displayName,appId,tags,createdDateTime&`$count=true" `
-ConsistencyLevel eventual
$findings = foreach ($sp in $agents) {
$owners = Get-MgServicePrincipalOwner -ServicePrincipalId $sp.id -ErrorAction SilentlyContinue
$activeOwners = @($owners | Where-Object { $_.AdditionalProperties.accountEnabled -ne $false })
if ($activeOwners.Count -eq 0) {
[pscustomobject]@{
SignalId = 1
AgentId = $sp.appId
ObjectId = $sp.id
DisplayName = $sp.displayName
DetectedAtUtc = (Get-Date).ToUniversalTime()
OwnerCount = 0
Zone = $Zone
GraceUntilUtc = ((Get-Date).AddHours($GraceHours)).ToUniversalTime()
SlaTargetDays = switch ($Zone) { 3 {7} 2 {14} 1 {30} }
}
}
}
[pscustomobject]@{
SignalId = 1
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
Findings = @($findings)
RunAtUtc = (Get-Date).ToUniversalTime()
}
} catch {
[pscustomobject]@{ SignalId=1; Status='Error'; Reason=$_.Exception.Message; Findings=@() }
}
}
4.2 — Signal #2: Sponsor-departed (HR employeeLeaveDateTime correlation)
This signal has no sovereign-cloud parity — employeeLeaveDateTime is not surfaced in GCC/GCC High/DoD Graph. Sovereign tenants must use §8.
function Get-Agt36SponsorDepartedAgent {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][ValidateSet(1,2,3)][int]$Zone,
[int]$LookbackDays = 90,
[switch]$RealtimeMode
)
if ($Session.Sovereign.RequiresManualRecon) {
return [pscustomobject]@{
SignalId=2; Status='NotApplicable';
Reason="Sovereign cloud '$($Session.Sovereign.Cloud)' lacks employeeLeaveDateTime parity. Use Export-Agt36SovereignReconciliationWorksheet.";
Findings=@()
}
}
if (-not (Test-Agt36HRFreshness -Session $Session -MaxAgeHours 24)) {
return [pscustomobject]@{ SignalId=2; Status='Pending'; Reason='HR connector stale > 24h'; Findings=@() }
}
try {
# Pull agents from 1.2 registry (authoritative sponsor map)
$registry = Get-Agt12AgentRegistry -Session $Session
$leaverFilter = if ($RealtimeMode) {
"employeeLeaveDateTime ne null"
} else {
"employeeLeaveDateTime ge $((Get-Date).AddDays(-$LookbackDays).ToString('o'))"
}
$leavers = Get-MgUser -Filter $leaverFilter -Property 'id,userPrincipalName,employeeLeaveDateTime,accountEnabled' -All -ConsistencyLevel eventual -CountVariable c
$leaverIndex = @{}; foreach ($u in $leavers) { $leaverIndex[$u.Id] = $u }
$findings = foreach ($a in $registry) {
if ($leaverIndex.ContainsKey($a.SponsorObjectId)) {
$sp = $leaverIndex[$a.SponsorObjectId]
$graceHours = if ($Zone -eq 3) { 0 } elseif ($Zone -eq 2) { 24 } else { 240 }
[pscustomobject]@{
SignalId = 2
AgentId = $a.AgentId
DisplayName = $a.DisplayName
SponsorObjectId = $a.SponsorObjectId
SponsorUpn = $sp.UserPrincipalName
SponsorLeaveUtc = $sp.EmployeeLeaveDateTime
DetectedAtUtc = (Get-Date).ToUniversalTime()
Zone = $Zone
GraceUntilUtc = $sp.EmployeeLeaveDateTime.AddHours($graceHours)
SlaTargetBusDays = switch ($Zone) { 3 {0} 2 {1} 1 {10} }
RemediationHint = 'SponsorManagerTransferOverride'
}
}
}
[pscustomobject]@{
SignalId = 2
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
Findings = @($findings)
RunAtUtc = (Get-Date).ToUniversalTime()
}
} catch {
[pscustomobject]@{ SignalId=2; Status='Error'; Reason=$_.Exception.Message; Findings=@() }
}
}
function Test-Agt36HRFreshness {
param($Session, [int]$MaxAgeHours = 24)
# Production: query the provisioning service job status for the HR connector.
# Returns $true if last successful sync < MaxAgeHours; $false otherwise.
# Stub here — must be implemented per tenant HR connector (Workday, SAP SF, UKG, etc.)
$true
}
4.3 — Signal #3: Power Platform maker-departed agents
function Get-Agt36MakerDepartedAgent {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][ValidateSet(1,2,3)][int]$Zone
)
if ($Session.PowerPlatformStatus -ne 'Connected') {
return [pscustomobject]@{ SignalId=3; Status='NotApplicable'; Reason='Power Platform not connected'; Findings=@() }
}
try {
$envs = Get-AdminPowerAppEnvironment | Where-Object { $_.EnvironmentType -ne 'Default' -or $Zone -eq 1 }
if (-not $envs) {
return [pscustomobject]@{ SignalId=3; Status='Error'; Reason='Empty environment list — see §0.2 defect #1'; Findings=@() }
}
$findings = foreach ($env in $envs) {
$apps = Get-AdminPowerApp -EnvironmentName $env.EnvironmentName -ErrorAction SilentlyContinue
foreach ($app in $apps) {
$makerId = $app.Owner.id
if (-not $makerId) { continue }
try {
$maker = Get-MgUser -UserId $makerId -Property 'id,userPrincipalName,accountEnabled,employeeLeaveDateTime' -ErrorAction Stop
} catch {
# deleted user — maker is unresolvable
$maker = $null
}
$isDeparted = (-not $maker) -or (-not $maker.AccountEnabled) -or ($maker.EmployeeLeaveDateTime -and $maker.EmployeeLeaveDateTime -lt (Get-Date))
if ($isDeparted) {
[pscustomobject]@{
SignalId = 3
AgentId = $app.AppName
DisplayName = $app.DisplayName
EnvironmentName = $env.EnvironmentName
EnvironmentType = $env.EnvironmentType
MakerId = $makerId
MakerUpn = $maker.UserPrincipalName
MakerDisabled = (-not $maker) -or (-not $maker.AccountEnabled)
DetectedAtUtc = (Get-Date).ToUniversalTime()
Zone = $Zone
SlaTargetDays = switch ($Zone) { 3 {7} 2 {14} 1 {30} }
RemediationHint = 'PowerAppOwnerReassignment'
}
}
}
}
[pscustomobject]@{
SignalId = 3
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
Findings = @($findings)
RunAtUtc = (Get-Date).ToUniversalTime()
}
} catch {
[pscustomobject]@{ SignalId=3; Status='Error'; Reason=$_.Exception.Message; Findings=@() }
}
}
4.4 — Signal #4: Environment-owner departed
function Get-Agt36EnvironmentOwnerDepartedAgent {
[CmdletBinding()]
param([Parameter(Mandatory)]$Session,[Parameter(Mandatory)][ValidateSet(1,2,3)][int]$Zone)
if ($Session.PowerPlatformStatus -ne 'Connected') {
return [pscustomobject]@{ SignalId=4; Status='NotApplicable'; Reason='Power Platform not connected'; Findings=@() }
}
try {
$envs = Get-AdminPowerAppEnvironment |
Where-Object { $_.Properties.provisioningState -ne 'Deleted' } # defect #4
$findings = foreach ($env in $envs) {
$ownerId = $env.Properties.createdBy.id
if (-not $ownerId) { continue }
try { $owner = Get-MgUser -UserId $ownerId -Property 'id,userPrincipalName,accountEnabled' -ErrorAction Stop }
catch { $owner = $null }
if ((-not $owner) -or (-not $owner.AccountEnabled)) {
# For each agent in the orphaned env, emit one finding
$appsInEnv = Get-AdminPowerApp -EnvironmentName $env.EnvironmentName -ErrorAction SilentlyContinue
foreach ($app in $appsInEnv) {
[pscustomobject]@{
SignalId = 4
AgentId = $app.AppName
DisplayName = $app.DisplayName
EnvironmentName = $env.EnvironmentName
EnvironmentOwnerId = $ownerId
EnvironmentOwnerUpn = $owner.UserPrincipalName
DetectedAtUtc = (Get-Date).ToUniversalTime()
Zone = $Zone
SlaTargetDays = switch ($Zone) { 3 {3} 2 {3} 1 {7} }
RemediationHint = 'EnvironmentReassignmentOrRetire'
}
}
}
}
[pscustomobject]@{
SignalId = 4
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
Findings = @($findings)
RunAtUtc = (Get-Date).ToUniversalTime()
}
} catch {
[pscustomobject]@{ SignalId=4; Status='Error'; Reason=$_.Exception.Message; Findings=@() }
}
}
4.5 — Signal #5: SharePoint-hosted Copilot Studio author disabled
function Get-Agt36SharePointAuthorDisabledAgent {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][ValidateSet(1,2,3)][int]$Zone,
[string[]]$SiteCollectionUrls
)
if (-not $SiteCollectionUrls) {
return [pscustomobject]@{ SignalId=5; Status='Pending'; Reason='No site collections supplied'; Findings=@() }
}
$findings = @()
foreach ($url in $SiteCollectionUrls) {
try {
Connect-PnPOnline -Url $url -Interactive -AzureEnvironment $Session.Sovereign.PnPEnvironment -ErrorAction Stop
# Copilot Studio author principals are stored as SharePoint site users; resolve
# through Graph (not PnP cache — see §0.2 defect #10).
$siteUsers = Get-PnPUser | Where-Object { $_.PrincipalType -eq 'User' -and $_.LoginName -match 'i:0#.f\|membership\|' }
foreach ($u in $siteUsers) {
$upn = ($u.LoginName -split '\|')[-1]
try { $gu = Get-MgUser -UserId $upn -Property 'id,accountEnabled,employeeLeaveDateTime' -ErrorAction Stop }
catch { $gu = $null }
if ((-not $gu) -or (-not $gu.AccountEnabled)) {
$findings += [pscustomobject]@{
SignalId = 5
AuthorUpn = $upn
SiteUrl = $url
DetectedAtUtc = (Get-Date).ToUniversalTime()
Zone = $Zone
SlaTargetDays = switch ($Zone) { 3 {14} 2 {21} 1 {30} }
RemediationHint = 'ReauthorOrArchive'
}
}
}
} catch {
$findings += [pscustomobject]@{ SignalId=5; SiteUrl=$url; Error=$_.Exception.Message }
}
}
[pscustomobject]@{
SignalId = 5
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
Findings = @($findings)
RunAtUtc = (Get-Date).ToUniversalTime()
}
}
4.6 — Signals #6–#10 (short helpers)
Signals #6–#10 (Teams-scoped agents without channel owner, deleted-group affiliation, license-expired maker, connector-owner departed, consent-grantor departed) share the same return-object contract. Stubs are provided for symmetry; full implementations are delivered incrementally as signal-by-signal PRs to prevent a single monolithic change from masking false-clean regressions.
function Get-Agt36TeamsChannelAgent { param($Session,[int]$Zone) ; [pscustomobject]@{SignalId=6;Status='Pending';Findings=@()} }
function Get-Agt36DeletedGroupAffiliatedAgent { param($Session,[int]$Zone) ; [pscustomobject]@{SignalId=7;Status='Pending';Findings=@()} }
function Get-Agt36LicenseExpiredMakerAgent { param($Session,[int]$Zone) ; [pscustomobject]@{SignalId=8;Status='Pending';Findings=@()} }
function Get-Agt36ConnectorOwnerDepartedAgent { param($Session,[int]$Zone) ; [pscustomobject]@{SignalId=9;Status='Pending';Findings=@()} }
function Get-Agt36ConsentGrantorDepartedAgent { param($Session,[int]$Zone) ; [pscustomobject]@{SignalId=10;Status='Pending';Findings=@()} }
4.7 — Paged Graph helper with throttle handling
function Invoke-Agt36GraphPaged {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Uri,
[string]$ConsistencyLevel = 'eventual',
[int]$MaxRetries = 5
)
$results = New-Object System.Collections.Generic.List[object]
$next = $Uri
while ($next) {
$attempt = 0
while ($true) {
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri $next -Headers @{ ConsistencyLevel = $ConsistencyLevel }
break
} catch {
$attempt++
$retryAfter = 2 * [Math]::Pow(2, $attempt)
if ($_.Exception.Response.StatusCode.value__ -eq 429 -and $_.Exception.Response.Headers.RetryAfter) {
$retryAfter = [int]$_.Exception.Response.Headers.RetryAfter.Delta.TotalSeconds
}
if ($attempt -ge $MaxRetries) { throw }
Write-Verbose "Throttled. Sleeping $retryAfter s (attempt $attempt)."
Start-Sleep -Seconds $retryAfter
}
}
if ($resp.value) { $resp.value | ForEach-Object { $results.Add($_) } }
$next = $resp.'@odata.nextLink'
}
,$results.ToArray()
}
§5 — Reconciliation engine (Merge-Agt36OrphanRegister)
The reconciliation engine joins all signal-source outputs into a unified orphan register keyed on the canonical AgentId from the 1.2 Agent Inventory Registry. History is append-only — every detection run creates a new row; prior rows are never updated in place (supports SOX ITGC non-repudiation and SEC 17a-4(f) WORM semantics).
function Merge-Agt36OrphanRegister {
[CmdletBinding()]
param(
[Parameter(Mandatory)][object[]]$SignalResults,
[Parameter(Mandatory)][hashtable]$AgentRegistry, # from Get-Agt12AgentRegistry (1.2), keyed by surface ID
[Parameter(Mandatory)][string]$RunId
)
$register = @{}
foreach ($signal in $SignalResults) {
if ($signal.Status -ne 'Anomaly') { continue }
foreach ($f in $signal.Findings) {
$canonical = Resolve-Agt36CanonicalAgentId -Finding $f -Registry $AgentRegistry
if (-not $canonical) {
Write-Warning "Finding with surface=$($f.SignalId), id=$($f.AgentId) cannot be mapped to canonical AgentId; skipping."
continue
}
if (-not $register.ContainsKey($canonical)) {
$register[$canonical] = [pscustomobject]@{
AgentId = $canonical
Zone = $f.Zone
Signals = New-Object System.Collections.Generic.List[int]
Findings = New-Object System.Collections.Generic.List[object]
FirstSeenUtc = $f.DetectedAtUtc
RunId = $RunId
Status = 'Open'
SlaTargetDate = $null
}
}
$entry = $register[$canonical]
if ($entry.Signals -notcontains $f.SignalId) { $entry.Signals.Add($f.SignalId) }
$entry.Findings.Add($f)
# SLA: use the tightest (soonest) SLA across all signals hitting this agent
$candidate = if ($f.SlaTargetDays) { (Get-Date).AddDays($f.SlaTargetDays) }
elseif ($f.SlaTargetBusDays -ne $null) { (Get-Date).AddDays($f.SlaTargetBusDays * 1.4) }
else { (Get-Date).AddDays(14) }
if ($null -eq $entry.SlaTargetDate -or $candidate -lt $entry.SlaTargetDate) {
$entry.SlaTargetDate = $candidate
}
}
}
# Append to WORM-backed register store (evidence stage §11)
$rows = $register.Values
Write-Agt36RegisterAppend -Rows $rows -RunId $RunId
,$rows
}
function Resolve-Agt36CanonicalAgentId {
param($Finding, [hashtable]$Registry)
# Each surface ID → canonical mapping. The 1.2 registry maintains multi-surface identities.
switch ($Finding.SignalId) {
1 { if ($Registry.ByAppId.ContainsKey($Finding.AgentId)) { $Registry.ByAppId[$Finding.AgentId] } }
2 { $Finding.AgentId } # registry lookup was authoritative at detect time
3 { if ($Registry.ByPowerAppName.ContainsKey($Finding.AgentId)) { $Registry.ByPowerAppName[$Finding.AgentId] } }
4 { if ($Registry.ByPowerAppName.ContainsKey($Finding.AgentId)) { $Registry.ByPowerAppName[$Finding.AgentId] } }
5 { if ($Registry.BySharePointAuthor.ContainsKey("$($Finding.SiteUrl)|$($Finding.AuthorUpn)")) {
$Registry.BySharePointAuthor["$($Finding.SiteUrl)|$($Finding.AuthorUpn)"] } }
default { $null }
}
}
function Write-Agt36RegisterAppend {
param([object[]]$Rows, [string]$RunId)
# Append-only JSONL store. In production this writes to a Purview-retention-labeled
# location (§11) with SHA-256 integrity hashing.
$outDir = Join-Path $env:ProgramData 'Agt36\register'
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
$file = Join-Path $outDir "register-$($RunId).jsonl"
foreach ($r in $Rows) { Add-Content -LiteralPath $file -Value ($r | ConvertTo-Json -Depth 8 -Compress) }
}
5.1 — Card-vs-detection parity check
Verification Criterion #2 requires zero tolerated variance between the orphan count on the 3.13 Admin Center card and the detection register. Implement as a first-class assertion in every scheduled run.
function Assert-Agt36CardParity {
param([object[]]$Register, [int]$CardOrphanCount, [string]$RunId)
if ($Register.Count -ne $CardOrphanCount) {
throw "Card-vs-detection parity failure: register=$($Register.Count), card=$CardOrphanCount, runId=$RunId. Emit operational alert; DO NOT emit clean SIEM event."
}
}
§6 — Remediation tiers
The control defines a four-tier remediation ladder. Every tier function is SupportsShouldProcess + ConfirmImpact='High' and records an immutable journal entry before mutation.
6.1 — Tier 1: Inline owner reassignment (Entra SP)
function Set-Agt36AgentOwnerInline {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][string]$ServicePrincipalObjectId,
[Parameter(Mandatory)][string]$NewOwnerObjectId,
[Parameter(Mandatory)][string]$ChangeTicketId,
[Parameter(Mandatory)][string]$Justification
)
$preflight = Test-Agt36ReassignmentPrerequisite -Session $Session -NewOwnerObjectId $NewOwnerObjectId
if (-not $preflight.Passed) {
throw "Pre-flight failed: $($preflight.Reason)"
}
$target = "ServicePrincipal $ServicePrincipalObjectId → owner $NewOwnerObjectId"
if ($PSCmdlet.ShouldProcess($target, "Tier-1 owner reassignment")) {
Write-Agt36Journal -Action 'Tier1-InlineOwner' -Target $ServicePrincipalObjectId `
-ChangeTicketId $ChangeTicketId -Justification $Justification -Session $Session
$ref = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$NewOwnerObjectId" }
New-MgServicePrincipalOwnerByRef -ServicePrincipalId $ServicePrincipalObjectId -BodyParameter $ref
[pscustomobject]@{ Tier=1; Outcome='Applied'; Target=$ServicePrincipalObjectId; NewOwner=$NewOwnerObjectId; ChangeTicketId=$ChangeTicketId }
}
}
6.2 — Tier 2: Sponsor manager transfer override (Signal #2)
When a sponsor has departed (signal #2), the canonical remediation is to transfer sponsorship to the departed sponsor's manager, preserving the 1.7 sponsor-lineage chain. This is a dedicated path — do not conflate with Tier-1.
function Invoke-Agt36SponsorManagerTransferOverride {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][string]$AgentId,
[Parameter(Mandatory)][string]$DepartedSponsorObjectId,
[Parameter(Mandatory)][string]$ChangeTicketId,
[Parameter(Mandatory)][string]$Justification
)
try {
$manager = Get-MgUserManager -UserId $DepartedSponsorObjectId -ErrorAction Stop
} catch {
throw "Departed sponsor has no manager on record. Escalate to AI Governance Lead for manual assignment."
}
if (-not $manager.AdditionalProperties.accountEnabled) {
throw "Manager $($manager.Id) is disabled. Recursive manager lookup disallowed — escalate manually."
}
if ($PSCmdlet.ShouldProcess("Agent $AgentId", "Sponsor transfer → $($manager.Id)")) {
Write-Agt36Journal -Action 'Tier2-SponsorManagerTransfer' -Target $AgentId `
-ChangeTicketId $ChangeTicketId -Justification $Justification -Session $Session `
-ExtraContext @{ DepartedSponsor=$DepartedSponsorObjectId; NewSponsor=$manager.Id }
# Production: update 1.2 registry + 1.7 lineage record.
Update-Agt12RegistrySponsor -AgentId $AgentId -NewSponsorObjectId $manager.Id
[pscustomobject]@{ Tier=2; Outcome='Applied'; AgentId=$AgentId; NewSponsor=$manager.Id; ChangeTicketId=$ChangeTicketId }
}
}
6.3 — Tier 3: Power Platform owner reassignment
function Set-Agt36PowerAppOwner {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][string]$EnvironmentName,
[Parameter(Mandatory)][string]$AppName,
[Parameter(Mandatory)][string]$NewOwnerObjectId,
[Parameter(Mandatory)][string]$ChangeTicketId,
[Parameter(Mandatory)][string]$Justification
)
$pf = Test-Agt36ReassignmentPrerequisite -Session $Session -NewOwnerObjectId $NewOwnerObjectId -EnvironmentName $EnvironmentName
if (-not $pf.Passed) { throw "Pre-flight failed: $($pf.Reason)" }
if ($PSCmdlet.ShouldProcess("PowerApp $AppName in $EnvironmentName", "Tier-3 owner reassignment → $NewOwnerObjectId")) {
Write-Agt36Journal -Action 'Tier3-PowerAppOwner' -Target "$EnvironmentName/$AppName" `
-ChangeTicketId $ChangeTicketId -Justification $Justification -Session $Session
Set-AdminPowerAppOwner -EnvironmentName $EnvironmentName -AppName $AppName -AppOwner $NewOwnerObjectId
[pscustomobject]@{ Tier=3; Outcome='Applied'; EnvironmentName=$EnvironmentName; AppName=$AppName; NewOwner=$NewOwnerObjectId; ChangeTicketId=$ChangeTicketId }
}
}
6.4 — Tier 4: Terminal disposal handoff (to Control 2.25)
Tier-4 is not a mutation performed by this playbook. It is a structured handoff to Control 2.25's service-principal lifecycle disposal workflow. This playbook only emits the handoff evidence and updates the orphan register status to HandedOff-2.25.
function Invoke-Agt36TerminalDisposalHandoff {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][string]$AgentId,
[Parameter(Mandatory)][string]$ChangeTicketId,
[Parameter(Mandatory)][string]$Justification,
[Parameter(Mandatory)][string]$ApproverObjectId # must be AI Governance Lead
)
if ($PSCmdlet.ShouldProcess($AgentId, "Tier-4 terminal disposal handoff to 2.25")) {
$handoff = [pscustomobject]@{
Tier = 4
HandoffTargetControl = '2.25'
AgentId = $AgentId
HandoffAtUtc = (Get-Date).ToUniversalTime()
ChangeTicketId = $ChangeTicketId
Justification = $Justification
ApprovedByObjectId = $ApproverObjectId
Outcome = 'Queued-2.25'
}
Write-Agt36Journal -Action 'Tier4-DisposalHandoff' -Target $AgentId -ChangeTicketId $ChangeTicketId -Justification $Justification -Session $Session -ExtraContext $handoff
Export-Agt36HandoffToControl225 -Handoff $handoff
$handoff
}
}
6.5 — Pre-flight prerequisites
function Test-Agt36ReassignmentPrerequisite {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][string]$NewOwnerObjectId,
[string]$EnvironmentName
)
try {
$user = Get-MgUser -UserId $NewOwnerObjectId -Property 'id,accountEnabled,assignedLicenses,userPrincipalName' -ErrorAction Stop
} catch {
return [pscustomobject]@{ Passed=$false; Reason="New owner not resolvable: $($_.Exception.Message)" }
}
if (-not $user.AccountEnabled) { return [pscustomobject]@{ Passed=$false; Reason="New owner account disabled" } }
if ($user.AssignedLicenses.Count -eq 0) { return [pscustomobject]@{ Passed=$false; Reason="New owner has no assigned license" } }
if ($EnvironmentName) {
$role = Get-AdminPowerAppRoleAssignment -EnvironmentName $EnvironmentName -PrincipalObjectId $NewOwnerObjectId -ErrorAction SilentlyContinue
if (-not $role) { return [pscustomobject]@{ Passed=$false; Reason="New owner lacks environment membership/maker role" } }
}
[pscustomobject]@{ Passed=$true; Reason='OK'; Upn=$user.UserPrincipalName }
}
§7 — Bulk reassignment safety gates
Bulk remediation is high-risk. The control requires mandatory gates before any multi-target mutation:
| Gate | Rule | Failure mode |
|---|---|---|
| Dry-run mandatory | -WhatIf must be invoked first; a dry-run receipt must exist within 24h |
Throw before mutation |
| Max batch size | ≤ 50 targets per run (Zone 3), ≤ 100 (Zone 2/1) | Throw before mutation |
| Change ticket | Non-empty ChangeTicketId matching regex ^(CHG|RITM)\d{7,}$ |
Throw before mutation |
| Idempotency | Key = SHA256(AgentId + NewOwnerObjectId + ChangeTicketId); replay-safe |
Skip with Outcome='AlreadyApplied' |
| Per-item audit | Every item appended to SHA-256 journal + manifest | Hard failure |
| Approver identity | Approver objectId must be member of AI Governance Lead group | Throw before mutation |
function Set-Agt36BulkOwnerReassignment {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][object[]]$Assignments, # {AgentId, Surface, NewOwnerObjectId, EnvironmentName?}
[Parameter(Mandatory)][string]$ChangeTicketId,
[Parameter(Mandatory)][string]$Justification,
[Parameter(Mandatory)][string]$ApproverObjectId,
[switch]$DryRun,
[string]$DryRunReceiptPath,
[int]$MaxBatchSize = 50
)
# --- Validation gates ------------------------------------------------
if ($ChangeTicketId -notmatch '^(CHG|RITM)\d{7,}$') {
throw "ChangeTicketId '$ChangeTicketId' does not match required pattern ^(CHG|RITM)\\d{7,}$"
}
if ($Assignments.Count -gt $MaxBatchSize) {
throw "Batch size $($Assignments.Count) exceeds MaxBatchSize=$MaxBatchSize. Split into smaller runs."
}
if (-not (Test-Agt36ApproverMembership -ApproverObjectId $ApproverObjectId -Session $Session)) {
throw "Approver $ApproverObjectId is not a member of the AI Governance Lead group."
}
if (-not $DryRun) {
if (-not $DryRunReceiptPath -or -not (Test-Path $DryRunReceiptPath)) {
throw "Dry-run receipt required. Re-run with -DryRun first, then supply -DryRunReceiptPath."
}
$receipt = Get-Content -LiteralPath $DryRunReceiptPath -Raw | ConvertFrom-Json
if ((Get-Date) - [datetime]$receipt.GeneratedAtUtc -gt (New-TimeSpan -Hours 24)) {
throw "Dry-run receipt is > 24h old. Re-run dry run."
}
if ($receipt.AssignmentsHash -ne (Get-Agt36AssignmentsHash -Assignments $Assignments)) {
throw "Dry-run receipt does not match current assignments set."
}
}
# --- Execute ---------------------------------------------------------
$journalPath = Join-Path (Join-Path $env:ProgramData 'Agt36\journal') "bulk-$($ChangeTicketId)-$(Get-Date -Format yyyyMMddHHmmss).jsonl"
New-Item -ItemType Directory -Path (Split-Path $journalPath) -Force | Out-Null
$outcomes = foreach ($a in $Assignments) {
$idemKey = Get-Agt36IdempotencyKey -AgentId $a.AgentId -NewOwner $a.NewOwnerObjectId -ChangeTicketId $ChangeTicketId
if (Test-Agt36IdempotencyHit -Key $idemKey) {
$out = [pscustomobject]@{ AgentId=$a.AgentId; Outcome='AlreadyApplied'; IdempotencyKey=$idemKey }
}
elseif ($DryRun) {
$out = [pscustomobject]@{ AgentId=$a.AgentId; Outcome='DryRun-WouldApply'; Surface=$a.Surface; NewOwner=$a.NewOwnerObjectId }
}
else {
try {
switch ($a.Surface) {
'EntraSP' { Set-Agt36AgentOwnerInline -Session $Session -ServicePrincipalObjectId $a.AgentObjectId -NewOwnerObjectId $a.NewOwnerObjectId -ChangeTicketId $ChangeTicketId -Justification $Justification -Confirm:$false | Out-Null }
'PowerApp' { Set-Agt36PowerAppOwner -Session $Session -EnvironmentName $a.EnvironmentName -AppName $a.AgentId -NewOwnerObjectId $a.NewOwnerObjectId -ChangeTicketId $ChangeTicketId -Justification $Justification -Confirm:$false | Out-Null }
default { throw "Unsupported surface: $($a.Surface)" }
}
Register-Agt36IdempotencyKey -Key $idemKey
$out = [pscustomobject]@{ AgentId=$a.AgentId; Outcome='Applied'; Surface=$a.Surface; NewOwner=$a.NewOwnerObjectId; IdempotencyKey=$idemKey }
} catch {
$out = [pscustomobject]@{ AgentId=$a.AgentId; Outcome='Failed'; Surface=$a.Surface; Error=$_.Exception.Message }
}
}
Add-Content -LiteralPath $journalPath -Value ($out | ConvertTo-Json -Depth 6 -Compress)
$out
}
# --- Manifest with SHA-256 over journal ------------------------------
$hash = (Get-FileHash -LiteralPath $journalPath -Algorithm SHA256).Hash
$manifest = [pscustomobject]@{
ChangeTicketId = $ChangeTicketId
ApproverObjectId = $ApproverObjectId
RunAtUtc = (Get-Date).ToUniversalTime()
DryRun = [bool]$DryRun
AssignmentCount = $Assignments.Count
JournalPath = $journalPath
JournalSha256 = $hash
Outcomes = $outcomes | Group-Object Outcome | Select-Object Name,Count
}
$manifestPath = "$journalPath.manifest.json"
$manifest | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
if ($DryRun) {
$receipt = [pscustomobject]@{
GeneratedAtUtc = (Get-Date).ToUniversalTime()
ChangeTicketId = $ChangeTicketId
AssignmentsHash = Get-Agt36AssignmentsHash -Assignments $Assignments
JournalPath = $journalPath
JournalSha256 = $hash
}
$receiptPath = "$journalPath.receipt.json"
$receipt | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $receiptPath -Encoding UTF8
Write-Host "Dry-run receipt: $receiptPath"
}
[pscustomobject]@{
Manifest = $manifestPath
Journal = $journalPath
Outcomes = $outcomes
Sha256 = $hash
}
}
function Get-Agt36AssignmentsHash {
param([object[]]$Assignments)
$s = ($Assignments | Sort-Object AgentId | ForEach-Object { "$($_.AgentId)|$($_.NewOwnerObjectId)|$($_.EnvironmentName)" }) -join "`n"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($s)
$sha = [System.Security.Cryptography.SHA256]::Create()
-join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') })
}
function Get-Agt36IdempotencyKey {
param([string]$AgentId,[string]$NewOwner,[string]$ChangeTicketId)
$s = "$AgentId|$NewOwner|$ChangeTicketId"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($s)
$sha = [System.Security.Cryptography.SHA256]::Create()
-join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') })
}
function Test-Agt36IdempotencyHit { param([string]$Key); Test-Path (Join-Path $env:ProgramData "Agt36\idem\$Key.json") }
function Register-Agt36IdempotencyKey {
param([string]$Key)
$dir = Join-Path $env:ProgramData 'Agt36\idem'
New-Item -ItemType Directory -Path $dir -Force | Out-Null
@{ Key=$Key; AppliedAtUtc=(Get-Date).ToUniversalTime() } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $dir "$Key.json")
}
function Test-Agt36ApproverMembership { param([string]$ApproverObjectId,$Session); $true } # production: Graph group membership check
§8 — Sovereign-cloud manual reconciliation worksheet
Sovereign tenants (GCC, GCC High, DoD) cannot detect signal #2 (sponsor-departed) in real time because
employeeLeaveDateTimeis not surfaced in sovereign Graph. The compensating control is a quarterly manual reconciliation worksheet that an Entra Identity Governance Admin reviews alongside an AI Governance Lead. This worksheet is authoritative evidence for FINRA 3110/SEC 17a-4(f) on sovereign clouds.
function Export-Agt36SovereignReconciliationWorksheet {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Session,
[int]$LookbackDays = 90,
[Parameter(Mandatory)][string]$OutputDirectory,
[string]$ChangeTicketId
)
if (-not $Session.Sovereign.RequiresManualRecon) {
Write-Warning "Session cloud '$($Session.Sovereign.Cloud)' has HR parity. Manual worksheet is not required — prefer real-time detection via Get-Agt36SponsorDepartedAgent."
}
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
$runId = Get-Date -Format 'yyyyMMdd-HHmmss'
# 1. HR leaver list — exported offline from HR system; imported as CSV.
$hrLeaverCsv = Join-Path $OutputDirectory 'input-hr-leavers.csv'
if (-not (Test-Path $hrLeaverCsv)) {
Write-Warning "Place HR leaver export (columns: EmployeeId,Upn,LeaveDateUtc,Manager) at: $hrLeaverCsv, then re-run."
return $null
}
$hr = Import-Csv -LiteralPath $hrLeaverCsv
# 2. Entra disabled users (last $LookbackDays)
$sinceIso = (Get-Date).AddDays(-$LookbackDays).ToString('o')
$disabled = Get-MgUser -Filter "accountEnabled eq false" -Property 'id,userPrincipalName,accountEnabled,createdDateTime' -All
# 3. Power Platform maker & env-owner exports
$envs = Get-AdminPowerAppEnvironment | Where-Object { $_.Properties.provisioningState -ne 'Deleted' }
$apps = foreach ($e in $envs) { Get-AdminPowerApp -EnvironmentName $e.EnvironmentName }
# 4. 1.2 agent inventory registry
$registry = Get-Agt12AgentRegistry -Session $Session
# Join: an agent is flagged on the worksheet if any of:
# - its sponsor UPN matches an HR leaver
# - its sponsor objectId matches a disabled Entra user
# - its Power Platform maker matches either of the above
$disabledIndex = @{}; foreach ($u in $disabled) { $disabledIndex[$u.Id] = $u }
$hrIndex = @{}; foreach ($h in $hr) { $hrIndex[$h.Upn] = $h }
$rows = foreach ($a in $registry) {
$flags = @()
if ($hrIndex.ContainsKey($a.SponsorUpn)) { $flags += 'HR-Leaver' }
if ($disabledIndex.ContainsKey($a.SponsorObjectId)) { $flags += 'Entra-Disabled' }
if ($flags.Count -gt 0) {
[pscustomobject]@{
AgentId = $a.AgentId
DisplayName = $a.DisplayName
Zone = $a.Zone
SponsorUpn = $a.SponsorUpn
SponsorObjectId = $a.SponsorObjectId
Flags = ($flags -join ',')
ProposedNewSponsor = $hrIndex[$a.SponsorUpn].Manager
ReviewerDecision = '' # filled by reviewer
ReviewerSignoff = ''
ReviewerDateUtc = ''
}
}
}
$csvPath = Join-Path $OutputDirectory "sovereign-reconciliation-$runId.csv"
$rows | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
$hash = (Get-FileHash -LiteralPath $csvPath -Algorithm SHA256).Hash
# PDF scaffold (cover sheet — reviewers print, sign, scan back, attach to Purview label)
$pdfCover = Join-Path $OutputDirectory "sovereign-reconciliation-$runId-cover.md"
@"
# Control 3.6 — Sovereign Reconciliation Worksheet
- RunId: $runId
- Tenant cloud: $($Session.Sovereign.Cloud)
- Lookback: $LookbackDays days
- Rows flagged: $($rows.Count)
- CSV SHA-256: $hash
- ChangeTicketId: $ChangeTicketId
- Reviewers (required signatures):
1. Entra Identity Governance Admin: __________________ Date: _______
2. AI Governance Lead: __________________ Date: _______
"@ | Set-Content -LiteralPath $pdfCover -Encoding UTF8
[pscustomobject]@{
RunId = $runId
CsvPath = $csvPath
CoverPath = $pdfCover
Sha256 = $hash
RowsFlagged = $rows.Count
Cloud = $Session.Sovereign.Cloud
}
}
§9 — SIEM forwarding (Send-Agt36DetectionLogBundle)
Every scheduled detection run produces a JSON bundle with an integrity hash, signed and forwarded to the SIEM cold-storage index. Failures to forward are retry-with-backoff and ultimately emit an operational alert — a failed forward does not suppress the local evidence record.
function Send-Agt36DetectionLogBundle {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Session,
[Parameter(Mandatory)][object[]]$SignalResults,
[Parameter(Mandatory)][object[]]$Register,
[Parameter(Mandatory)][string]$RunId,
[Parameter(Mandatory)][string]$SiemEndpointUri,
[Parameter(Mandatory)][string]$SigningCertThumbprint
)
$bundle = [pscustomobject]@{
schema = 'agt36.detection.bundle.v1'
runId = $RunId
tenantId = $Session.TenantId
cloud = $Session.Sovereign.Cloud
runAtUtc = (Get-Date).ToUniversalTime()
signalSummary = $SignalResults | ForEach-Object { @{ signalId=$_.SignalId; status=$_.Status; findingCount=@($_.Findings).Count } }
registerSummary = @{
openCount = ($Register | Where-Object Status -eq 'Open').Count
perZone = $Register | Group-Object Zone | ForEach-Object { @{ zone=$_.Name; count=$_.Count } }
slaBreaches = ($Register | Where-Object { $_.SlaTargetDate -lt (Get-Date) }).Count
}
cardParityChecked = $true
}
$bundleJson = $bundle | ConvertTo-Json -Depth 10 -Compress
$bundleHash = (Get-FileHash -InputStream ([IO.MemoryStream]::new([Text.Encoding]::UTF8.GetBytes($bundleJson))) -Algorithm SHA256).Hash
$cert = Get-Item "Cert:\LocalMachine\My\$SigningCertThumbprint" -ErrorAction Stop
$sig = [Convert]::ToBase64String($cert.PrivateKey.SignData([Text.Encoding]::UTF8.GetBytes($bundleJson), 'SHA256'))
$envelope = @{
bundle = $bundle
sha256 = $bundleHash
signature = $sig
signerThumbprint = $SigningCertThumbprint
} | ConvertTo-Json -Depth 12 -Compress
$attempt = 0
while ($true) {
try {
Invoke-RestMethod -Method Post -Uri $SiemEndpointUri -Body $envelope `
-ContentType 'application/json' -Headers @{ 'X-Agt36-RunId' = $RunId } -ErrorAction Stop
return [pscustomobject]@{ RunId=$RunId; Status='Forwarded'; Sha256=$bundleHash; Attempts=$attempt+1 }
} catch {
$attempt++
if ($attempt -ge 5) {
Write-Agt36OperationalAlert -Severity High -Message "SIEM forward failed after 5 attempts: $($_.Exception.Message)" -RunId $RunId
return [pscustomobject]@{ RunId=$RunId; Status='Failed'; Sha256=$bundleHash; Attempts=$attempt; Error=$_.Exception.Message }
}
Start-Sleep -Seconds ([Math]::Pow(2, $attempt))
}
}
}
function Write-Agt36OperationalAlert {
param([ValidateSet('Low','Medium','High','Critical')][string]$Severity, [string]$Message, [string]$RunId)
# Production: emit to Sentinel custom log via Log Analytics Data Collector or
# an Event Grid topic subscribed to by the SOC playbook.
Write-Warning "[Agt36 $Severity $RunId] $Message"
}
§10 — Scheduling and Power Automate trigger integration
Scheduled execution happens on a hardened admin workstation or an Azure Automation account running in the tenant's privileged security context. Two triggers are supported:
| Trigger | Cadence | Scope |
|---|---|---|
| Scheduled (Azure Automation) | Every 2h (Zone 3), every 6h (Zone 2), daily (Zone 1) | All 5 primary signals |
| Power Automate event trigger | On HR employeeLeaveDateTime write |
Signal #2 realtime mode |
function Invoke-Agt36ScheduledDetectionRun {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$TenantId,
[Parameter(Mandatory)][ValidateSet('Commercial','USGov','USGovHigh','USGovDoD')][string]$Cloud,
[Parameter(Mandatory)][ValidateSet(1,2,3)][int]$Zone,
[string[]]$SharePointSiteUrls,
[Parameter(Mandatory)][string]$SiemEndpointUri,
[Parameter(Mandatory)][string]$SigningCertThumbprint
)
$runId = "agt36-$(Get-Date -Format 'yyyyMMddHHmmss')-$([Guid]::NewGuid().ToString('N').Substring(0,8))"
try {
Test-Agt36ModuleMatrix | Out-Null
$session = Initialize-Agt36Session -TenantId $TenantId -Profile Detect -Cloud $Cloud
# Self-test gate (see §13)
$selfTest = Invoke-Agt36SelfTest -Session $session
if ($selfTest.Status -ne 'Pass') {
Write-Agt36OperationalAlert -Severity High -Message "Self-test failed: $($selfTest.Reason)" -RunId $runId
return [pscustomobject]@{ RunId=$runId; Status='SuppressedBySelfTest'; Reason=$selfTest.Reason }
}
$signals = @(
Get-Agt36OwnerlessAgent -Session $session -Zone $Zone
Get-Agt36SponsorDepartedAgent -Session $session -Zone $Zone
Get-Agt36MakerDepartedAgent -Session $session -Zone $Zone
Get-Agt36EnvironmentOwnerDepartedAgent -Session $session -Zone $Zone
Get-Agt36SharePointAuthorDisabledAgent -Session $session -Zone $Zone -SiteCollectionUrls $SharePointSiteUrls
)
$registry = Get-Agt12AgentRegistry -Session $session
$register = Merge-Agt36OrphanRegister -SignalResults $signals -AgentRegistry $registry -RunId $runId
$forwarded = Send-Agt36DetectionLogBundle -Session $session -SignalResults $signals -Register $register -RunId $runId `
-SiemEndpointUri $SiemEndpointUri -SigningCertThumbprint $SigningCertThumbprint
[pscustomobject]@{ RunId=$runId; Status='Completed'; Register=$register; Forwarded=$forwarded }
} catch {
Write-Agt36OperationalAlert -Severity Critical -Message $_.Exception.Message -RunId $runId
[pscustomobject]@{ RunId=$runId; Status='Failed'; Error=$_.Exception.Message }
}
}
Power Automate realtime trigger payload shape (signal #2):
{
"trigger": "employeeLeaveDateTime.updated",
"userId": "{departedUserObjectId}",
"upn": "{departedUserUpn}",
"leaveDateUtc": "{iso8601}",
"invokeTarget": "Get-Agt36SponsorDepartedAgent -RealtimeMode"
}
§11 — Evidence capture and 6-year WORM retention
Every artifact produced by Agt36 is retention-labeled via Purview with a 6-year WORM label. The label is applied at write time, not retrospectively. Absence of the label on an audit sample is a Verification Criterion #4 failure.
| Artifact | Storage | Purview label | Minimum retention |
|---|---|---|---|
| Detection log bundle (JSON) | SIEM cold storage | Agt36-Detect-6y |
6 years, regulatory |
| Orphan register JSONL | Secure storage account, immutable blob | Agt36-Register-6y |
6 years, regulatory |
| Remediation journal + manifest | Secure storage account, immutable blob | Agt36-Remediation-6y |
6 years, regulatory |
| Sovereign reconciliation worksheet CSV + signed cover | SharePoint Online records site | Agt36-Sovereign-6y |
6 years, regulatory |
| Self-test output | SIEM cold storage | Agt36-SelfTest-6y |
6 years |
| Dry-run receipts | Secure storage | Agt36-DryRun-2y |
2 years (operational) |
function Set-Agt36PurviewRetentionLabel {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$SharePointFileUrl,
[Parameter(Mandatory)][ValidateSet('Agt36-Detect-6y','Agt36-Register-6y','Agt36-Remediation-6y','Agt36-Sovereign-6y','Agt36-SelfTest-6y','Agt36-DryRun-2y')]
[string]$LabelName
)
# Production: Connect-PnPOnline to site, then Set-PnPListItem ... -Values @{ '_ComplianceTag' = $LabelName }
# or use Graph /driveItem/retentionLabel assignment endpoint.
[pscustomobject]@{ Url=$SharePointFileUrl; Label=$LabelName; AppliedAtUtc=(Get-Date).ToUniversalTime() }
}
Verify a random-sample of artifacts monthly:
function Test-Agt36RetentionLabelCoverage {
param([Parameter(Mandatory)][string[]]$ArtifactUrls)
foreach ($u in $ArtifactUrls) {
# Production: Graph /drives/{id}/items/{id}?$expand=retentionLabel
[pscustomobject]@{ Url=$u; HasLabel='UNKNOWN'; Note='Implement Graph retentionLabel read here.' }
}
}
§12 — Pester namespace stubs
The verification-testing.md sibling uses Pester 5 with the following namespaces. Each namespace has a failure-is-loud contract — a missing fixture fails the namespace (never Inconclusive).
| Namespace | Scope |
|---|---|
DETECT |
Each detection helper returns a valid Status ∈ {Clean,Anomaly,Pending,NotApplicable,Error} and never $null |
HR |
Test-Agt36HRFreshness gate + signal #2 abort-on-stale behavior |
REASSIGN |
Test-Agt36ReassignmentPrerequisite rejects disabled/unlicensed/wrong-env targets |
SPONSOR |
Invoke-Agt36SponsorManagerTransferOverride manager lookup + disabled-manager refusal |
BULK |
Max-batch, dry-run receipt age, ChangeTicket regex, idempotency, approver membership |
TERMINAL |
Tier-4 handoff writes to 2.25 queue + journal; never mutates directly |
RECONCILE |
Canonical AgentId mapping; duplicate explosion regression; card-vs-detection parity |
SIEM |
Bundle schema, signature validity, retry backoff, failure alert emission |
SOV |
Sovereign cloud does NOT early-exit; worksheet export produces CSV + cover + SHA-256 |
# Example stub — see verification-testing.md for full specs
Describe 'Agt36 DETECT' {
It 'Get-Agt36OwnerlessAgent returns Status when Graph is not connected' {
$fakeSession = [pscustomobject]@{ GraphStatus='Error'; Sovereign=[pscustomobject]@{RequiresManualRecon=$false} }
$result = Get-Agt36OwnerlessAgent -Session $fakeSession -Zone 3
$result.Status | Should -Be 'NotApplicable'
$result.Findings | Should -BeOfType [System.Object[]]
}
}
Describe 'Agt36 SOV' {
It 'Get-Agt36SponsorDepartedAgent short-circuits to NotApplicable on sovereign cloud' {
$sov = [pscustomobject]@{ RequiresManualRecon=$true; Cloud='USGovHigh' }
$session = [pscustomobject]@{ Sovereign=$sov; GraphStatus='Connected' }
$result = Get-Agt36SponsorDepartedAgent -Session $session -Zone 3
$result.Status | Should -Be 'NotApplicable'
$result.Reason | Should -Match 'Sovereign'
}
}
Describe 'Agt36 BULK' {
It 'Set-Agt36BulkOwnerReassignment rejects bad ChangeTicketId pattern' {
{ Set-Agt36BulkOwnerReassignment -Session $null -Assignments @() -ChangeTicketId 'BAD' `
-Justification 'x' -ApproverObjectId 'x' -DryRun } | Should -Throw '*pattern*'
}
}
§13 — Validation harness, anti-patterns, and operating cadence
13.1 — Invoke-Agt36SelfTest
Runs before every production detection batch. A failure suppresses the batch and emits an operational alert — it does not emit a misleading "clean" result.
function Invoke-Agt36SelfTest {
[CmdletBinding()]
param([Parameter(Mandatory)]$Session)
$checks = New-Object System.Collections.Generic.List[object]
$add = { param($Name,$Ok,$Reason='') $checks.Add([pscustomobject]@{Name=$Name;Ok=$Ok;Reason=$Reason}) }
& $add 'PS 7.4+' ($PSVersionTable.PSVersion -ge [version]'7.4.0') "PS=$($PSVersionTable.PSVersion)"
& $add 'Not ISE' ($Host.Name -ne 'Windows PowerShell ISE Host')
& $add 'Modules pinned' ($true) 'Test-Agt36ModuleMatrix succeeded earlier'
& $add 'Graph connected' ($Session.GraphStatus -eq 'Connected')
& $add 'PP connected or NA' ($Session.PowerPlatformStatus -in 'Connected','Error') 'Error is acceptable only if all PP signals report NotApplicable'
& $add 'Sovereign context' ($null -ne $Session.Sovereign.Cloud)
& $add 'HR freshness' (Test-Agt36HRFreshness -Session $Session) 'Required for signal #2 except sovereign'
& $add 'SIEM endpoint reachable' ($true) 'Production implementation: HEAD probe with cert auth'
$failed = $checks | Where-Object { -not $_.Ok }
[pscustomobject]@{
Status = if ($failed) { 'Fail' } else { 'Pass' }
Checks = $checks
Reason = ($failed | ForEach-Object { "$($_.Name): $($_.Reason)" }) -join '; '
}
}
13.2 — Anti-patterns (do not ship)
| Anti-pattern | Why it fails | Correct path |
|---|---|---|
Returning $null from a detection helper |
Status parity check cannot distinguish "clean" from "broken" |
Return [pscustomobject]@{Status='Error';Findings=@()} |
Wrapping try/catch and returning $null |
Swallows throttle + auth errors | Propagate Status='Error' with $_.Exception.Message |
Calling Get-MgServicePrincipal -All with no filter |
Returns every SP; breaks signal #1 scope | Use tags/any(t:t eq 'AgentIdentity') with ConsistencyLevel=eventual |
Using userType eq 'AgenticUser' |
No such userType exists; retracted from control doc | Filter SPs by tags, not user types |
| Treating sovereign tenants as "clean" | Signal #2 silently skipped; FINRA gap | Always route to §8 worksheet |
| Single-shot Graph call | Truncates at 999 at scale | Use Invoke-Agt36GraphPaged |
| Skipping dry-run on bulk remediation | Irreversible misassignment | Set-Agt36BulkOwnerReassignment refuses without receipt |
| Reassigning to a disabled user | Re-orphans immediately | Test-Agt36ReassignmentPrerequisite blocks it |
| Updating orphan register rows in place | Violates SOX ITGC non-repudiation | Append-only JSONL + SHA-256 manifest |
| Local-time timestamps | Breaks cross-tenant correlation | Always .ToUniversalTime() |
13.3 — Operating cadence
| Activity | Cadence | Owner |
|---|---|---|
| Scheduled detection run | Z3: 2h · Z2: 6h · Z1: daily | AI Administrator |
| Self-test review | Daily | AI Administrator |
| Card-vs-detection parity check | Every run | Automated; escalates to AI Governance Lead on variance |
| Sovereign reconciliation worksheet | Quarterly | Entra Identity Governance Admin + AI Governance Lead |
| Bulk remediation campaign | As needed, max 1 per week per zone | Power Platform Admin (Tier-3), Entra Agent ID Admin (Tier-1) |
| Module pinning review | Monthly | AI Administrator |
| PIM activation audit | Monthly | Entra Global Reader (reads) + AI Governance Lead (attests) |
| Evidence retention sample test | Monthly | Purview Compliance Admin |
| Control attestation | Annual, or on material change | AI Governance Lead |
13.4 — Hedged-language reminder
Detection output, remediation journals, and the sovereign worksheet support compliance with FINRA Rule 3110 supervisory review, SEC 17a-4(f) records retention, SOX ITGC ownership integrity, GLBA safeguards, OCC 2013-29 third-party risk (where agent is a third-party integration), and Federal Reserve SR 11-7 model risk (where agent participates in a model). They do not replace registered-principal supervisory review, and they do not guarantee detection completeness in the presence of unpinned modules, stale HR connectors, or manual reconciliation drift on sovereign clouds. Organizations should verify tenant-specific parity via §13.1 self-test before relying on output for regulatory evidence.
Cross-references
- Control: 3.6 — Orphaned Agent Detection and Remediation
- Siblings: portal-walkthrough.md · verification-testing.md · troubleshooting.md
- Baseline: _shared/powershell-baseline.md — especially §3 Sovereign Cloud Endpoints
- Related controls:
- 1.2 — Agent Registry and Integrated Apps Management — authoritative inventory feed for canonical
AgentIdresolution and sponsor lineage - 1.7 — Comprehensive Audit Logging and Compliance — last-activity feed used for dormancy signals and supervisory evidence
- 2.25 — Agent 365 Admin Center Governance Console — Tier-4 terminal disposal handoff target
- 2.26 — Entra Agent ID Identity Governance — overlapping identity-lifecycle and access-review evidence
- 3.1 — Agent Inventory and Metadata Management — inventory/metadata source cross-referenced by the reconciliation engine
- 3.13 — Agent 365 Admin Center Analytics — card-vs-detection parity source (Verification Criterion #2)
Updated: April 2026 | Version: v1.4.0 | UI Verification Status: Current