PowerShell Setup: Control 1.27 — AI Agent Content Moderation Enforcement
Read the FSI PowerShell baseline first
Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, the Write-FsiEvidence SHA-256 evidence pattern, and the Desktop-edition guard. Snippets below intentionally re-use those patterns; the baseline is authoritative.
Last Updated: April 2026
Primary Modules: Microsoft.PowerApps.Administration.PowerShell (Desktop / Windows PowerShell 5.1 only), ExchangeOnlineManagement (Search-UnifiedAuditLog).
Prerequisites
- Role: Power Platform Admin (cross-environment inventory) or Entra Global Admin. For Script 2 audit queries, also assign Purview Audit Reader (least-privilege) or Purview Compliance Admin.
- PowerShell edition: Windows PowerShell 5.1 (Desktop) is required for
Microsoft.PowerApps.Administration.PowerShell. The Desktop guard from the baseline (§2) is included in every script below — do not remove it. - Sovereign cloud: if your tenant is GCC / GCC High / DoD, pass the correct
-Endpointvalue toAdd-PowerAppsAccount. Otherwise the cmdlet authenticates against commercial endpoints and returns zero environments — producing false-clean evidence. See baseline §3. - Module pinning: baseline §1. Substitute
<version>with the version approved by your CAB.
Module Installation (Canonical Pattern)
# Pinned per FSI baseline §1. Replace <version> with CAB-approved value.
Install-Module -Name Microsoft.PowerApps.Administration.PowerShell `
-RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module -Name ExchangeOnlineManagement `
-RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
# Sovereign-aware sign-in (baseline §3)
param(
[ValidateSet('prod','usgov','usgovhigh','dod')] [string]$Endpoint = 'prod'
)
Add-PowerAppsAccount -Endpoint $Endpoint
# Replace admin@yourdomain.com with your privileged-access workstation account
Connect-ExchangeOnline -UserPrincipalName admin@yourdomain.com -ShowBanner:$false
API Surface — Verify Before Production Use
As of April 2026, Get-AdminPowerAppChatbot exposes most agent metadata, but the property path for content moderation (e.g., Properties.ContentModeration.DefaultLevel) is not documented as a stable schema. The scripts below are inventory and reporting templates — they probe the property and degrade gracefully when it is absent. Run the pre-flight probe first:
Get-AdminPowerAppEnvironment | Select-Object -First 1 | ForEach-Object {
Get-AdminPowerAppChatbot -EnvironmentName $_.EnvironmentName -ErrorAction SilentlyContinue |
Select-Object -First 1 | ConvertTo-Json -Depth 6
}
If ContentModeration is absent, fall back to manual inventory via Portal Walkthrough Step 5 and document that decision in the evidence pack. The Dataverse botcomponents table (Script 4) is the authoritative source for per-topic moderation in tenants where the admin cmdlet does not surface it.
Shared evidence helper (used by all scripts)
# Re-use Write-FsiEvidence from the baseline (§5). Inlined here for self-containment.
function Write-FsiEvidence {
param(
[Parameter(Mandatory)] $Object,
[Parameter(Mandatory)] [string]$Name,
[Parameter(Mandatory)] [string]$EvidencePath,
[string]$ScriptVersion = '1.27.0'
)
if (-not (Test-Path $EvidencePath)) { New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null }
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
$jsonPath = Join-Path $EvidencePath ("{0}-{1}.json" -f $Name, $ts)
$Object | ConvertTo-Json -Depth 20 | Set-Content -Path $jsonPath -Encoding UTF8
$hash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
$manifestPath = Join-Path $EvidencePath 'manifest.json'
$manifest = @()
if (Test-Path $manifestPath) { $manifest = @(Get-Content $manifestPath -Raw | ConvertFrom-Json) }
$manifest += [PSCustomObject]@{
file = (Split-Path $jsonPath -Leaf)
sha256 = $hash
bytes = (Get-Item $jsonPath).Length
generated_utc = $ts
script_version = $ScriptVersion
control_id = '1.27'
}
$manifest | ConvertTo-Json -Depth 6 | Set-Content -Path $manifestPath -Encoding UTF8
return [PSCustomObject]@{ Path = $jsonPath; Sha256 = $hash }
}
Script 1 — Get-AgentModerationInventory.ps1 (read-only, idempotent)
<#
.SYNOPSIS
Cross-environment inventory of Copilot Studio agents and their effective
content moderation defaults. Read-only. Idempotent (safe to re-run).
.OUTPUTS
Evidence: AgentModerationInventory-<utc>.json + SHA-256 entry in manifest.json
#>
[CmdletBinding()]
param(
[string]$EvidencePath = ".\evidence\1.27",
[ValidateSet('prod','usgov','usgovhigh','dod')] [string]$Endpoint = 'prod',
[string[]]$EnvironmentFilter # optional: limit to named environments
)
$ErrorActionPreference = 'Stop'
# Baseline §2 — Desktop guard
if ($PSVersionTable.PSEdition -ne 'Desktop') {
throw "Microsoft.PowerApps.Administration.PowerShell requires Windows PowerShell 5.1 (Desktop). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)."
}
Add-PowerAppsAccount -Endpoint $Endpoint | Out-Null
$inventory = New-Object System.Collections.Generic.List[object]
$envs = Get-AdminPowerAppEnvironment
if ($EnvironmentFilter) { $envs = $envs | Where-Object { $EnvironmentFilter -contains $_.EnvironmentName } }
foreach ($env in $envs) {
Write-Host "[1.27] Environment: $($env.DisplayName)" -ForegroundColor Cyan
$agents = Get-AdminPowerAppChatbot -EnvironmentName $env.EnvironmentName -ErrorAction SilentlyContinue
foreach ($agent in $agents) {
$cm = $agent.Properties.ContentModeration
$lvl = if ($cm -and $cm.DefaultLevel) { [string]$cm.DefaultLevel } else { 'NotExposedByApi' }
$msg = if ($cm -and $cm.SafetyMessage) { $true } else { $false }
$inventory.Add([PSCustomObject]@{
EnvironmentDisplayName = $env.DisplayName
EnvironmentId = $env.EnvironmentName
AgentDisplayName = $agent.Properties.DisplayName
BotId = $agent.ChatbotName
EffectiveDefaultLevel = $lvl
CustomSafetyMessageSet = $msg
LastModifiedUtc = $agent.Properties.LastModifiedTime
GovernanceZone = 'NEEDS_CLASSIFICATION' # join with AgentZoneMapping.csv
ApprovalStatus = 'NEEDS_REVIEW'
CapturedUtc = (Get-Date).ToUniversalTime().ToString('o')
})
}
}
Write-FsiEvidence -Object $inventory -Name 'AgentModerationInventory' -EvidencePath $EvidencePath
Write-Host "[1.27] Inventory rows: $($inventory.Count)" -ForegroundColor Green
Idempotency note. This script writes a new timestamped JSON each run and appends to manifest.json. It does not mutate any tenant state.
Script 2 — Get-ModerationConfigChanges.ps1 (audit log query, read-only)
<#
.SYNOPSIS
Queries the Unified Audit Log for Copilot Studio admin operations that touch
content moderation. Read-only.
.NOTES
Operation names below are anticipated. Run a broad discovery query first
(no -Operations filter) and adjust the $opPattern list to match your tenant.
#>
[CmdletBinding()]
param(
[int]$DaysBack = 30,
[string]$EvidencePath = ".\evidence\1.27",
[string[]]$OpPatterns = @('UpdateChatbot','UpdateBot','ModifyModeration','PublishChatbot')
)
$ErrorActionPreference = 'Stop'
if (-not (Get-Command Search-UnifiedAuditLog -ErrorAction SilentlyContinue)) {
throw "Search-UnifiedAuditLog not available. Install ExchangeOnlineManagement and run Connect-ExchangeOnline first."
}
$start = (Get-Date).AddDays(-[Math]::Abs($DaysBack))
$end = Get-Date
Write-Host "[1.27] Querying Unified Audit Log $start → $end" -ForegroundColor Cyan
$raw = Search-UnifiedAuditLog -StartDate $start -EndDate $end `
-RecordType PowerPlatformAdminActivity -ResultSize 5000 -ErrorAction SilentlyContinue
$matches = foreach ($e in $raw) {
$op = [string]$e.Operations
if ($OpPatterns | Where-Object { $op -like "*$_*" }) {
$audit = try { $e.AuditData | ConvertFrom-Json } catch { $null }
[PSCustomObject]@{
CreationDateUtc = $e.CreationDate
Operation = $op
User = $e.UserIds
EnvironmentId = $audit.EnvironmentName
BotId = $audit.ChatbotName
RawAuditData = $audit
}
}
}
Write-FsiEvidence -Object @{
QueryWindowStart = $start.ToUniversalTime().ToString('o')
QueryWindowEnd = $end.ToUniversalTime().ToString('o')
OperationFilters = $OpPatterns
EventCount = ($matches | Measure-Object).Count
Events = $matches
} -Name 'ModerationConfigChanges' -EvidencePath $EvidencePath
Write-Host "[1.27] Matching events: $(($matches | Measure-Object).Count)" -ForegroundColor Green
Script 3 — Test-ZoneCompliance.ps1 (drift detection, read-only)
<#
.SYNOPSIS
Joins the inventory from Script 1 with an AgentZoneMapping.csv and flags
drift from required moderation level per zone.
.PARAMETER ZoneMappingFile
CSV: BotId,AgentDisplayName,GovernanceZone,RequiredModeration
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$InventoryJsonPath,
[Parameter(Mandatory)] [string]$ZoneMappingFile,
[string]$EvidencePath = ".\evidence\1.27"
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $InventoryJsonPath)) { throw "Inventory not found: $InventoryJsonPath" }
if (-not (Test-Path $ZoneMappingFile)) { throw "Zone mapping not found: $ZoneMappingFile" }
$inv = Get-Content $InventoryJsonPath -Raw | ConvertFrom-Json
$map = Import-Csv -Path $ZoneMappingFile
$results = foreach ($a in $inv) {
$z = $map | Where-Object { $_.BotId -eq $a.BotId } | Select-Object -First 1
$required = if ($z) { $z.RequiredModeration } else { 'UNKNOWN' }
$zone = if ($z) { $z.GovernanceZone } else { 'UNCLASSIFIED' }
# Treat "Medium" and "Moderate" as synonyms (UI vs Learn terminology)
$norm = { param($s) ($s -replace '(?i)^medium$','Moderate') }
$compliant = ((& $norm $a.EffectiveDefaultLevel) -ieq (& $norm $required))
[PSCustomObject]@{
EnvironmentDisplayName = $a.EnvironmentDisplayName
AgentDisplayName = $a.AgentDisplayName
BotId = $a.BotId
Zone = $zone
RequiredModeration = $required
ActualModeration = $a.EffectiveDefaultLevel
Compliant = $compliant
Severity = if (-not $compliant -and $zone -eq 'Zone 3') { 'High' }
elseif (-not $compliant -and $zone -eq 'Zone 2') { 'Medium' }
elseif (-not $compliant) { 'Low' }
else { 'None' }
}
}
$summary = [PSCustomObject]@{
Total = ($results | Measure-Object).Count
Compliant = ($results | Where-Object Compliant).Count
NonCompliant = ($results | Where-Object { -not $_.Compliant }).Count
HighSeverity = ($results | Where-Object { $_.Severity -eq 'High' }).Count
Results = $results
}
Write-FsiEvidence -Object $summary -Name 'ZoneComplianceReport' -EvidencePath $EvidencePath
$summary | Select-Object Total, Compliant, NonCompliant, HighSeverity | Format-Table -AutoSize
Script 4 — Get-TopicModerationOverrides.ps1 (Dataverse Web API)
No PowerShell admin cmdlet exposes per-topic moderation. The authoritative source is the Dataverse
botcomponentsentity (filtercomponenttype eq 0for topics) inside the agent's environment. This script issues an authenticated GET and emits hashed evidence.
<#
.SYNOPSIS
Pulls per-topic Generative answers nodes for a given agent via Dataverse Web API.
.PARAMETER OrgUrl
e.g., https://contoso.crm.dynamics.com (or .crm9. for GCC, .crm.appsplatform.us for GCC High, etc.)
.PARAMETER BotId
The Dataverse bot id (ChatbotName from Script 1 output).
.PARAMETER AccessToken
Bearer token with Dataverse user impersonation rights for the target org.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$OrgUrl,
[Parameter(Mandatory)] [string]$BotId,
[Parameter(Mandatory)] [string]$AccessToken,
[string]$EvidencePath = ".\evidence\1.27"
)
$ErrorActionPreference = 'Stop'
$uri = "$OrgUrl/api/data/v9.2/botcomponents?`$filter=_parentbotid_value eq $BotId and componenttype eq 0&`$select=name,componentidunique,content"
$headers = @{
Authorization = "Bearer $AccessToken"
'OData-Version' = '4.0'
'OData-MaxVersion' = '4.0'
Accept = 'application/json'
}
$resp = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET
$topics = foreach ($t in $resp.value) {
# Topic content is a YAML/JSON blob. We probe for any "moderation" key but do not parse the entire schema.
$contentRaw = [string]$t.content
$hasModeration = $contentRaw -match '(?i)contentmoderation|moderationlevel'
[PSCustomObject]@{
TopicName = $t.name
ComponentId = $t.componentidunique
HasGenerativeAnswers= ($contentRaw -match '(?i)GenerativeAnswers')
HasModerationToken = $hasModeration
ContentPreviewSha256= ([System.BitConverter]::ToString(
[System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($contentRaw))).Replace('-',''))
}
}
Write-FsiEvidence -Object @{
BotId = $BotId
OrgUrl = $OrgUrl
Topics = $topics
} -Name "TopicModerationOverrides-$BotId" -EvidencePath $EvidencePath
$topics | Format-Table -AutoSize
Why hash content instead of dumping it. Topic YAML may contain prompt text, knowledge source URIs, or PII patterns — capturing it verbatim into evidence can create new SEC 17a-4 / GLBA exposure. The hash supports tamper-evidence without the raw content.
Sample AgentZoneMapping.csv
BotId,AgentDisplayName,GovernanceZone,RequiredModeration
00000000-0000-0000-0000-000000000001,Customer Support Agent,Zone 3,High
00000000-0000-0000-0000-000000000002,HR Benefits Assistant,Zone 2,High
00000000-0000-0000-0000-000000000003,Personal Task Helper,Zone 1,Moderate
Scheduling and Cadence
| Script | Zone 1 | Zone 2 | Zone 3 |
|---|---|---|---|
Get-AgentModerationInventory.ps1 |
Quarterly | Monthly | Weekly |
Get-ModerationConfigChanges.ps1 (audit log) |
Quarterly | Monthly | Daily between formal weekly reviews |
Test-ZoneCompliance.ps1 |
Quarterly | Monthly | Weekly |
Get-TopicModerationOverrides.ps1 |
On change | On change | Pre-publish + weekly |
Known Limitations
| Limitation | Impact | Mitigation |
|---|---|---|
Properties.ContentModeration is not a documented stable schema |
Inventory may report NotExposedByApi |
Fall back to portal inventory; document in evidence pack |
| No PS cmdlet for per-topic moderation | Script 4 requires Dataverse Web API + bearer token | Use service principal with least-privilege Dataverse role |
| Unified Audit Log default retention is 90 days (180 days with audit add-on) | Long-tail moderation history is lost | Forward to Sentinel / external SIEM with WORM retention (SEC 17a-4(f)) |
| Audit log operation names not all documented | Filters in Script 2 may miss events | Run discovery query first; widen $OpPatterns |
Microsoft.PowerApps.Administration.PowerShell is Desktop-only |
Scripts must run on Windows PS 5.1 | Desktop guard built into every script; do not strip it |
Back to Control 1.27 | Portal Walkthrough | Verification & Testing | Troubleshooting