Skip to content

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 -Endpoint value to Add-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 botcomponents entity (filter componenttype eq 0 for 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