PowerShell Setup: Control 2.18 — Automated Conflict of Interest Testing
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, and SHA-256 evidence emission. Snippets below show abbreviated patterns; the baseline is authoritative.
Last Updated: April 2026
Modules Required: Microsoft.Graph (audit log queries), Microsoft.PowerApps.Administration.PowerShell (agent inventory), ImportExcel (reporting). Custom HTTP calls to the Copilot Studio evaluation endpoint use the agent's REST surface — no dedicated PowerShell module exists for evaluation runs as of April 2026.
Tenant Footprint: Read-only against tenant data; writes only to the local evidence working directory and (optionally) to a configured SharePoint evidence library.
This playbook automates the execution, evidence collection, and validation layers of Control 2.18. The portal walkthrough remains the canonical place to configure test sets and graders; PowerShell is for running the suite, hashing the artefacts, and producing the compliance pack.
1. Prerequisites and Module Setup
# Pin module versions per the FSI PowerShell baseline. Replace <version>
# placeholders with the versions approved by your Change Advisory Board.
$modules = @(
@{ Name = 'Microsoft.Graph'; Version = '<approved-version>' },
@{ Name = 'Microsoft.PowerApps.Administration.PowerShell'; Version = '<approved-version>' },
@{ Name = 'ImportExcel'; Version = '<approved-version>' }
)
foreach ($m in $modules) {
if (-not (Get-Module -ListAvailable -Name $m.Name | Where-Object Version -eq $m.Version)) {
Install-Module -Name $m.Name `
-RequiredVersion $m.Version `
-Repository PSGallery `
-Scope CurrentUser `
-AllowClobber `
-AcceptLicense
}
}
# Sovereign-cloud guard — adjust per the baseline. Default below is Commercial.
$Cloud = 'Commercial' # Commercial | GCC | GCCHigh | DoD
$GraphEnv = 'Global' # Global | USGov | USGovDoD | China
$PowerAppsEndpt = 'prod' # prod | usgov | usgovhigh | dod | china
# Microsoft.PowerApps.Administration.PowerShell requires Windows PowerShell 5.1.
if ($PSVersionTable.PSEdition -ne 'Desktop' -and $RequirePowerAppsAdmin) {
throw "Microsoft.PowerApps.Administration.PowerShell requires Windows PowerShell 5.1 (Desktop edition). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)."
}
2. Run the COI Evaluation Test Suite
This script invokes the Copilot Studio agent's evaluation endpoint (or the agent's published API) for each scenario in a test set, evaluates responses against per-scenario criteria, and produces a CSV result file plus a JSON detail file with hashes.
<#
.SYNOPSIS
Executes the automated COI test suite against a Copilot Studio agent.
.DESCRIPTION
Loads JSON test cases from -TestSuitePath, calls the agent endpoint for
each prompt, evaluates the response against per-scenario criteria, and
writes time-stamped CSV (summary) + JSON (detail) artefacts with SHA-256
hashes recorded in an evidence-register CSV.
Read-only against tenant data. Writes only under -OutputPath.
Supports -WhatIf for dry runs (no agent calls executed).
.PARAMETER AgentEndpoint
The agent API or evaluation endpoint URL.
.PARAMETER ApiToken
Bearer token for the agent endpoint. Source from Azure Key Vault — never
embed in scripts. Service-principal auth is preferred for FINRA Rule 3110
supervisory continuity.
.PARAMETER TestSuitePath
Folder containing one JSON file per test case. Each JSON must include:
category, name, prompt, criteria[]
Each criterion is { type: contains|not_contains|regex|min_length, pattern: ... }.
.PARAMETER OutputPath
Folder where CSV summary, JSON detail, and evidence register are written.
.EXAMPLE
.\Invoke-COITests.ps1 -AgentEndpoint 'https://...' `
-ApiToken (Get-Secret -Name 'coi-eval-token') `
-TestSuitePath '.\test_cases' -OutputPath '.\evidence' -WhatIf
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
param(
[Parameter(Mandatory)] [string] $AgentEndpoint,
[Parameter(Mandatory)] [string] $ApiToken,
[string] $TestSuitePath = '.\test_cases',
[string] $OutputPath = '.\evidence',
[int] $TimeoutSec = 60
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $OutputPath | Out-Null
function Test-Criterion {
param($Response, $Criterion)
switch ($Criterion.type) {
'contains' { return [bool]($Response -match [regex]::Escape($Criterion.pattern)) }
'not_contains' { return [bool]($Response -notmatch [regex]::Escape($Criterion.pattern)) }
'regex' { return [bool]($Response -match $Criterion.pattern) }
'min_length' { return [bool]($Response.Length -ge [int]$Criterion.pattern) }
default { Write-Warning "Unknown criterion type: $($Criterion.type)"; return $true }
}
}
$testFiles = Get-ChildItem -Path $TestSuitePath -Filter '*.json' -Recurse
if (-not $testFiles) { throw "No test cases found under $TestSuitePath" }
$results = foreach ($file in $testFiles) {
$tc = Get-Content $file.FullName -Raw | ConvertFrom-Json
Write-Verbose "Running $($tc.category)/$($tc.name)"
if ($PSCmdlet.ShouldProcess($AgentEndpoint, "POST evaluation for $($tc.name)")) {
try {
$body = @{ message = $tc.prompt; context = $tc.context } | ConvertTo-Json -Depth 5
$resp = Invoke-RestMethod -Uri $AgentEndpoint -Method Post `
-Headers @{ Authorization = "Bearer $ApiToken" } `
-Body $body -ContentType 'application/json' -TimeoutSec $TimeoutSec
$violations = @()
foreach ($c in $tc.criteria) {
if (-not (Test-Criterion -Response $resp.message -Criterion $c)) {
$violations += $c.name
}
}
[PSCustomObject]@{
TestFile = $file.Name
Category = $tc.category
TestName = $tc.name
Passed = ($violations.Count -eq 0)
Violations = ($violations -join '; ')
Response = $resp.message
TimestampUtc = (Get-Date).ToUniversalTime().ToString('o')
}
}
catch {
[PSCustomObject]@{
TestFile = $file.Name
Category = $tc.category
TestName = $tc.name
Passed = $false
Violations = "ExecutionError: $($_.Exception.Message)"
Response = $null
TimestampUtc = (Get-Date).ToUniversalTime().ToString('o')
}
}
}
}
if (-not $results) {
Write-Warning "Dry run (-WhatIf): no agent calls executed. Exiting."
return
}
# Summary
$total = $results.Count
$passed = ($results | Where-Object Passed).Count
$rate = [math]::Round(($passed / $total) * 100, 1)
Write-Host ("=== COI Suite: {0}/{1} passed ({2}%) ===" -f $passed, $total, $rate)
# Persist artefacts
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$summary = Join-Path $OutputPath "Control-2.18_TestResults_$stamp.csv"
$detail = Join-Path $OutputPath "Control-2.18_TestResultsDetail_$stamp.json"
$register = Join-Path $OutputPath "Control-2.18_EvidenceRegister.csv"
$results | Select-Object TestFile, Category, TestName, Passed, Violations, TimestampUtc |
Export-Csv -Path $summary -NoTypeInformation -Encoding UTF8
$results | ConvertTo-Json -Depth 6 | Out-File -FilePath $detail -Encoding UTF8
# Chain-of-custody hashes
$entries = foreach ($f in @($summary, $detail)) {
$hash = (Get-FileHash -Path $f -Algorithm SHA256).Hash
[PSCustomObject]@{
FileName = Split-Path $f -Leaf
Sha256 = $hash
SizeBytes = (Get-Item $f).Length
CapturedUtc = (Get-Date).ToUniversalTime().ToString('o')
Operator = $env:USERNAME
AgentEndpoint = $AgentEndpoint
}
}
$entries | Export-Csv -Path $register -NoTypeInformation -Append -Encoding UTF8
Write-Host "Evidence written:`n $summary`n $detail`n $register"
# Exit non-zero on failures so CI / Power Automate can detect regressions
if ($passed -lt $total) { exit 1 }
Token handling.
-ApiTokenshould be sourced from Azure Key Vault (Get-AzKeyVaultSecret) or an equivalent secret broker. Embedding a bearer token in a script file violates GLBA 501(b) Safeguards Rule expectations and is an audit finding.
3. Generate the COI Compliance Report
Aggregates the most recent N CSV exports into a single Markdown report suitable for Compliance and AI Risk Committee distribution.
[CmdletBinding()]
param(
[string] $EvidencePath = '.\evidence',
[string] $ReportPath = '.\reports',
[int] $LookbackRuns = 5
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $ReportPath | Out-Null
$resultFiles = Get-ChildItem -Path $EvidencePath -Filter 'Control-2.18_TestResults_*.csv' |
Sort-Object LastWriteTime -Descending | Select-Object -First $LookbackRuns
if (-not $resultFiles) { throw "No result files found under $EvidencePath" }
$all = $resultFiles | ForEach-Object { Import-Csv $_.FullName }
$byCategory = $all | Group-Object Category | ForEach-Object {
$passed = ($_.Group | Where-Object { $_.Passed -eq 'True' }).Count
[PSCustomObject]@{
Category = $_.Name
Total = $_.Count
Passed = $passed
Failed = $_.Count - $passed
PassRate = [math]::Round(($passed / $_.Count) * 100, 1)
}
}
$failures = $all | Where-Object { $_.Passed -eq 'False' } | Select-Object -First 25
$report = @"
# Control 2.18 — COI Testing Compliance Report
**Generated (UTC):** $((Get-Date).ToUniversalTime().ToString('o'))
**Runs included:** $($resultFiles.Count) (most recent)
**Total test executions:** $($all.Count)
## Pass rate by category
| Category | Total | Passed | Failed | Pass Rate |
|----------|-------|--------|--------|-----------|
$(($byCategory | ForEach-Object { "| $($_.Category) | $($_.Total) | $($_.Passed) | $($_.Failed) | $($_.PassRate)% |" }) -join "`n")
## Recent failures (up to 25)
$(if ($failures) {
($failures | ForEach-Object { "- ``[$($_.Category)]`` $($_.TestName) — $($_.Violations)" }) -join "`n"
} else { "_None._" })
## Recommendation
$(if ($failures) {
"Review failed cases with Compliance and the Agent Owner. Trigger remediation per WSPs and re-run the suite before promoting any further agent change."
} else {
"All sampled runs pass. Maintain recurring execution and quarterly methodology review."
})
---
*Generated by Control-2.18 reporting script. Cross-reference with the evidence register for SHA-256 chain of custody.*
"@
$reportFile = Join-Path $ReportPath ("Control-2.18_ComplianceReport_{0}.md" -f (Get-Date -Format 'yyyyMMdd'))
$report | Out-File -FilePath $reportFile -Encoding UTF8
Write-Host "Report written: $reportFile"
4. Validate Control 2.18 Configuration (Read-Only)
This validator inventories agents in scope, confirms recent evaluation runs, and verifies evidence-register integrity. It performs no writes against tenant data.
<#
.SYNOPSIS
Read-only validation of Control 2.18 configuration and evidence.
.DESCRIPTION
1. Inventories Copilot Studio / Power Platform agents tagged as
in-scope for COI testing.
2. Confirms a recent evaluation run exists per agent (within
-MaxRunAgeDays).
3. Recomputes SHA-256 hashes against the evidence register and reports
drift.
Returns a structured object suitable for piping to a dashboard or CI gate.
#>
[CmdletBinding()]
param(
[string] $EvidencePath = '.\evidence',
[int] $MaxRunAgeDays = 35,
[string[]] $InScopeAgentTags = @('coi-required')
)
$ErrorActionPreference = 'Stop'
# 1. Recent evaluation runs
$latest = Get-ChildItem -Path $EvidencePath -Filter 'Control-2.18_TestResults_*.csv' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
$recentRunOk = $latest -and ($latest.LastWriteTime -gt (Get-Date).AddDays(-$MaxRunAgeDays))
# 2. Evidence-register hash integrity
$register = Join-Path $EvidencePath 'Control-2.18_EvidenceRegister.csv'
$drift = @()
if (Test-Path $register) {
Import-Csv $register | ForEach-Object {
$path = Join-Path $EvidencePath $_.FileName
if (Test-Path $path) {
$current = (Get-FileHash -Path $path -Algorithm SHA256).Hash
if ($current -ne $_.Sha256) {
$drift += [PSCustomObject]@{
FileName = $_.FileName
RegisteredHash = $_.Sha256
CurrentHash = $current
}
}
} else {
$drift += [PSCustomObject]@{
FileName = $_.FileName
RegisteredHash = $_.Sha256
CurrentHash = '<missing>'
}
}
}
}
[PSCustomObject]@{
LatestRunFile = $latest.FullName
LatestRunTimestamp = $latest.LastWriteTime
RecentRunOk = [bool]$recentRunOk
EvidenceRegister = (Test-Path $register)
HashDriftCount = $drift.Count
HashDriftDetail = $drift
InScopeAgentTags = $InScopeAgentTags
Cloud = $Cloud
GeneratedUtc = (Get-Date).ToUniversalTime().ToString('o')
}
What this validator does not do. It does not vouch for the content of the evaluation (that is the job of the graders and Compliance review). It confirms the operational discipline — that runs are happening on cadence and that artefacts have not been altered after the fact.
5. Operational Reminders
- Mutation safety. Every script in this playbook honours
-WhatIfand-Confirm(SupportsShouldProcess). Run with-WhatIffirst against any new agent endpoint. - No tenant writes. This control's PowerShell footprint is read-only against tenant configuration. The only writes are local evidence files (and optionally a configured evidence library).
- Service-principal execution. Recurring runs must execute under a service principal owned by the Compliance or AI Governance function — not under a personal account. This is a FINRA Rule 3110 supervisory-continuity expectation.
- Sovereign clouds. Re-validate endpoints (
-Endpoint,-Environment) against the PowerShell Authoring Baseline before running in GCC / GCC High / DoD tenants. Wrong endpoints produce false-clean results. - Evidence retention. Move CSV / JSON / register files into the SharePoint Compliance Evidence Library on completion; do not leave Zone 3 evidence on a workstation longer than necessary.
Back to Control 2.18 | Portal Walkthrough | Verification & Testing | Troubleshooting