Control 2.24 — PowerShell Setup: Agent Feature Enablement and Restriction Governance
Control reference: Control 2.24 — Agent Feature Enablement and Restriction Governance
Sister playbooks: Portal Walkthrough · Verification & Testing · Troubleshooting
Shared baseline: PowerShell Baseline & Sovereign Cloud Endpoints
The scripts in this playbook aid in discovering which Microsoft 365 / Power Platform / Copilot Studio / Agent Framework features are currently enabled in a tenant, help meet the SOX-302, FINRA 3110/4511/25-07, Fed SR 11-7, OCC 2011-12, FFIEC IT-RM, GLBA 501(b), and SEC Reg SCI evidence expectations summarised in the parent control, and support compliance with change-management gating for any feature toggle that affects an in-scope agent. They do not themselves authorize a feature to be enabled, replace Model Risk Management review (Control 2.6), substitute for the supervision program (Control 2.12), or grant publishing authorization (Control 1.1). Every output is a signal that an Agent Governance Lead, AI Administrator, or Change Advisory Board member must reconcile against an approved Feature Catalog entry before declaring the tenant state compliant. Implementation requires the canonical role assignments listed in docs/reference/role-catalog.md; organizations should verify each helper's behaviour in a non-production tenant before scheduling it.
⚠️ Sovereign cloud parity gap (April 2026). Several Microsoft 365 Copilot admin center surfaces, Agent Framework feature flags, and MCP connector enumeration endpoints have not reached parity in GCC, GCC High, or DoD as of the
last_ui_verifieddate above. The bootstrap helper in §2 redirects sovereign callers into the §11 compensating runner rather than throwing, and produces a separate per-cloud catalog plus a parity-diff report. Treat any "Clean" status emitted by a commercial-cloud helper running against a sovereign tenant asAnomalyuntil the parity tracker in §11 confirms the surface is generally available. See Sovereign Cloud Endpoints (GCC / GCC High / DoD).
Scope
| # | Automation area | Primary helper | Pester namespace |
|---|---|---|---|
| 1 | Module pinning, Graph scope matrix, RBAC + PIM gating | Initialize-Feat224Session |
(prereq) |
| 2 | Cloud-aware bootstrap with sovereign redirect | Resolve-Feat224CloudProfile |
(prereq) |
| 3 | Dataverse Feature Catalog table provisioning | Deploy-Feat224FeatureCatalog |
CATALOG |
| 4 | Microsoft 365 admin center / Copilot hub reader | Get-Feat224M365FeatureState |
M365HUB |
| 5 | Power Platform Admin Center reader | Get-Feat224PpacFeatureState |
PPAC, ENV |
| 6 | Copilot Studio agent feature reader | Get-Feat224AgentFeatureState |
AGENT |
| 7 | DLP policy ↔ feature catalog correlation | Compare-Feat224DlpAlignment |
DLP |
| 8 | Zone (1/2/3) compliance diff | Compare-Feat224ZoneCompliance |
ZONE |
| 9 | MCP connector + Agent Framework flag enumeration | Get-Feat224McpAgfState |
MCP, AGF |
| 10 | Change-management evidence exporter | Export-Feat224ChangeEvidence |
CHANGE |
| 11 | Sovereign-cloud compensating runner | Invoke-Feat224SovereignRegister |
SOV |
| 12 | Sentinel / SIEM forwarding | Send-Feat224SiemEvent |
SIEM |
| 13 | Scheduling (Az Automation / scheduled jobs) | Register-Feat224Schedule |
(operational) |
| 14 | Retention alignment with Control 2.13 | Set-Feat224EvidenceRetention |
(operational) |
| 15 | Cross-control references | (documentation) | — |
Audience
These scripts are written for AI Administrators (the primary owner of tenant-level Microsoft 365 Copilot toggles per the v1.3.3 patch to Control 2.24), Power Platform Admins (environment-scope feature flags and DLP), AI Governance Leads (catalog ownership and exception adjudication), Compliance Officers (evidence sign-off), the Change Management Team (CAB ticket reconciliation), and Agent Owners (per-agent attestations). Entra Global Admin is reserved for exceptional changes only — do not run mutation helpers under a permanent Global Admin assignment; use PIM eligible-activation as shown in §1.
§0 — Wrong-shell traps and the false-clean defect catalog
Before running anything in this playbook, confirm you are in the correct elevated shell and that prior Graph / Power Platform sessions are not silently masking failures. The following defect catalog enumerates the fifteen most common ways a 2.24 helper has historically returned Status = 'Clean' when the tenant was, in fact, non-compliant. Every helper in §§3–12 includes a Reason populated from this catalog whenever it downgrades a result to Anomaly.
Wrong-shell traps
| # | Symptom | Root cause | Mitigation |
|---|---|---|---|
| W1 | Connect-MgGraph returns instantly with no prompt |
Cached delegated token from a different tenant | Run Disconnect-MgGraph; Clear-MgContext before §2 bootstrap |
| W2 | Add-PowerAppsAccount succeeds in Windows PowerShell 5.1 but Get-AdminPowerApp throws MethodNotFound |
Module loaded into the wrong runtime | Use PowerShell 7.4+ (pwsh) exclusively; Assert-Feat224ShellHost blocks 5.1 |
| W3 | Sovereign tenant returns commercial-cloud feature flags | -Endpoint prod defaulted instead of usgov / usgovhigh / dod |
Resolve-Feat224CloudProfile (§2) sets $env:PowerAppsEndpoint per cloud |
| W4 | Pester BeforeAll runs as a different identity than It blocks |
Mixed device-code + cert auth in one session | Pin a single auth method per shell; Initialize-Feat224Session enforces |
| W5 | Invoke-MgGraphRequest 401 silently swallowed by try/catch returning @() |
Helper treats empty array as Clean | All §§4–9 helpers emit Status = 'Error' on caught 4xx/5xx |
False-clean defect catalog
| # | Defect | What looks Clean | Why it is actually Anomaly | Helper that catches it |
|---|---|---|---|---|
| F1 | Catalog row exists but ExpirationDate is in the past |
Feature appears governed | Catalog entry is stale; CAB approval lapsed | Compare-Feat224ZoneCompliance |
| F2 | Feature toggle ON in tenant, ON in catalog, but ChangeTicket field empty |
Governance metadata "matches" | No SOX-404 / FINRA 4511 evidence trail | Export-Feat224ChangeEvidence |
| F3 | Copilot Studio agent has Generative Answers enabled but catalog scope is Zone 2 only and the agent is published to Zone 3 |
Per-agent flag matches catalog default | Zone elevation requires explicit re-approval per Control 2.2 | Compare-Feat224ZoneCompliance |
| F4 | DLP policy blocks a connector, but the agent's MCP server tunnels the same capability | Connector blocked at the platform layer | MCP bypass; SR 11-7 model-input control gap | Get-Feat224McpAgfState + Compare-Feat224DlpAlignment |
| F5 | M365 admin center shows Copilot Pages Off, but a Graph beta endpoint reports it enabled per group |
Tenant-wide toggle "matches" catalog Off |
Group-scoped override drift; FINRA 3110 supervision blind spot | Get-Feat224M365FeatureState |
| F6 | PPAC environment has feature On, environment is in Default environment group |
Feature governed at Default group | Default group is reserved for personal productivity (Zone 1) and must not host shared agents per Control 2.2 | Get-Feat224PpacFeatureState |
| F7 | Agent Framework workflow tool registered, no entry in catalog | Workflow does not appear in the M365 hub | AGF feature flags are not surfaced in the admin center; require direct enumeration | Get-Feat224McpAgfState |
| F8 | Sovereign tenant returns no CopilotPages configuration |
Helper interprets null as Off |
The endpoint is Not Available in GCC High; null ≠ Off |
Invoke-Feat224SovereignRegister |
| F9 | Catalog row marks a feature Approved, but ApprovalDate precedes the feature's GA date |
Approval looks valid | CAB approved an unreleased capability; revisit per OCC 2011-12 | Deploy-Feat224FeatureCatalog validator |
| F10 | Per-agent override disables a feature the tenant has enabled | Conservative posture | Hidden override removes a control the supervision program (2.12) is monitoring | Get-Feat224AgentFeatureState |
| F11 | Evidence manifest written, but hash chain breaks vs. previous run | Manifest exists | Tampering or unsynced clock; SOX-302 attestation invalid | New-Feat224EvidenceManifest |
| F12 | Send-Feat224SiemEvent returns HTTP 204, no canary echo |
"Forwarded" | DCR-side filter dropped the event; supervision feed unreliable | Send-Feat224SiemEvent canary mode |
| F13 | Feature added to catalog but never observed in tenant | Catalog "Clean" | Catalog drift; helper should mark Pending until a tenant observation lands |
Compare-Feat224ZoneCompliance |
| F14 | Two AI Administrators enabled the same toggle within the change window | Single ticket on file | Dual-control violation if the second admin was the requester | Export-Feat224ChangeEvidence segregation check |
| F15 | A feature is enabled tenant-wide but no agent uses it | Looks compliant | Latent attack surface; SR 11-7 expects the inventory to flag unused-but-enabled capabilities | Compare-Feat224ZoneCompliance (UnusedEnabled finding) |
Non-substitution anchor. None of the helpers in this playbook substitute for: (a) Control 1.1 publishing authorization, (b) Control 2.6 Model Risk Management review, or (c) Control 2.12 supervisory review of agent communications. A "Clean" result here means the configuration matches the approved catalog — it does not certify that the catalog itself reflects a defensible risk decision.
§1 — Prerequisites: modules, Graph scopes, RBAC, and PIM gating
1.1 Pinned module set
Pin to these versions for the v1.4 (April 2026) baseline. Newer point releases generally work but have not been UI-verified.
# Pinned module manifest for Control 2.24 helpers — verified 2026-04-15
$Feat224Modules = @(
@{ Name = 'Microsoft.Graph'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Beta'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Authentication'; Version = '2.25.0' },
@{ Name = 'Microsoft.PowerApps.Administration.PowerShell';Version = '2.0.188' },
@{ Name = 'Microsoft.PowerApps.PowerShell'; Version = '1.0.34' },
@{ Name = 'Microsoft.Xrm.Data.PowerShell'; Version = '2.8.84' },
@{ Name = 'Az.Accounts'; Version = '2.15.0' },
@{ Name = 'Az.OperationalInsights'; Version = '3.6.0' },
@{ Name = 'Pester'; Version = '5.5.0' }
)
function Install-Feat224Modules {
[CmdletBinding(SupportsShouldProcess)]
param([switch] $Force)
foreach ($m in $Feat224Modules) {
$installed = Get-Module -ListAvailable -Name $m.Name |
Where-Object { $_.Version -eq [version]$m.Version }
if ($installed -and -not $Force) { continue }
if ($PSCmdlet.ShouldProcess("$($m.Name) $($m.Version)", 'Install-Module')) {
Install-Module -Name $m.Name -RequiredVersion $m.Version `
-Scope CurrentUser -Force -AllowClobber -Repository PSGallery
}
}
}
1.2 Microsoft Graph scope matrix
| Helper | Delegated scope(s) | Application scope(s) | Cloud parity |
|---|---|---|---|
Get-Feat224M365FeatureState |
CopilotSettings.Read.All, Directory.Read.All |
CopilotSettings.Read.All |
GCC partial; GCC High gap |
Get-Feat224AgentFeatureState |
CopilotStudio.Read.All, Bot.Read.All (beta) |
CopilotStudio.Read.All |
GCC partial; GCC High/DoD gap |
Get-Feat224McpAgfState |
Agent.Read.All (beta), Application.Read.All |
Agent.Read.All |
All sovereign clouds gap |
Compare-Feat224DlpAlignment |
n/a (PPAC SDK) | n/a | Commercial + GCC; GCC High partial |
Export-Feat224ChangeEvidence |
AuditLog.Read.All, SecurityEvents.Read.All |
AuditLog.Read.All |
All clouds (delayed in sovereign) |
Send-Feat224SiemEvent |
n/a (Az + DCR) | Monitoring Metrics Publisher on DCR | All clouds |
1.3 RBAC requirements (canonical role names)
| Activity | Required role | Notes |
|---|---|---|
| Read tenant Copilot toggles | AI Administrator (preferred) or Reports Reader | Per the v1.3.3 patch to Control 2.24, AI Administrator is the standing role for tenant-level reads |
| Mutate tenant Copilot toggles | AI Administrator with PIM activation | Entra Global Admin reserved for exceptional changes only |
| Read Power Platform environments | Power Platform Admin (Reader is insufficient for feature-flag enumeration) | |
Provision Dataverse fsi_featurecatalog table |
System Customizer + Power Platform Admin | Idempotent — see §3 |
| Read Copilot Studio agent definitions | Copilot Studio Maker + Power Platform Admin | |
| Forward to Sentinel | Monitoring Metrics Publisher on the DCR | Resource-scoped, not tenant-wide |
| Sign evidence manifests | AI Governance Lead (key holder) | Manifests countersigned by Compliance Officer |
1.4 PIM eligible-activation helper
function Request-Feat224PimActivation {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [ValidateSet('AIAdministrator','PowerPlatformAdmin','GlobalAdministrator')]
[string] $Role,
[Parameter(Mandatory)] [string] $JustificationTicket,
[ValidateRange(15, 480)] [int] $DurationMinutes = 60
)
if ($Role -eq 'GlobalAdministrator') {
Write-Warning "Global Administrator activation should be exceptional. Confirm a CAB ticket beyond '$JustificationTicket' is on file (Control 2.24 v1.4.0)."
}
$context = Get-MgContext
if (-not $context) { throw "Run Initialize-Feat224Session before requesting PIM activation." }
$roleDef = Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq '$Role'"
if (-not $roleDef) {
return [pscustomobject]@{
Status = 'Error'
Reason = "Role definition '$Role' not found in tenant $($context.TenantId)."
Role = $Role
}
}
if ($PSCmdlet.ShouldProcess($Role, "Activate PIM for $DurationMinutes minutes")) {
$body = @{
action = 'selfActivate'
principalId = (Get-MgUser -UserId $context.Account).Id
roleDefinitionId = $roleDef.Id
directoryScopeId = '/'
justification = "Control 2.24 evidence run — ticket $JustificationTicket"
scheduleInfo = @{
startDateTime = (Get-Date).ToUniversalTime().ToString('o')
expiration = @{ type = 'AfterDuration'; duration = "PT${DurationMinutes}M" }
}
}
try {
$req = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $body
return [pscustomobject]@{
Status = 'Clean'
Role = $Role
AssignmentId = $req.Id
ExpiresUtc = (Get-Date).ToUniversalTime().AddMinutes($DurationMinutes)
JustificationTicket = $JustificationTicket
}
} catch {
return [pscustomobject]@{
Status = 'Error'
Reason = $_.Exception.Message
Role = $Role
}
}
}
}
Hedged claim: PIM activation aids in segregation-of-duties enforcement under SOX-404 and FFIEC IT-RM, but does not by itself satisfy the dual-control expectation. Defect F14 in §0 must be evaluated by
Export-Feat224ChangeEvidence(§10).
§2 — Cloud-aware bootstrap with sovereign redirect
2.1 Preview-feature gating
Several beta endpoints required by §6 (Copilot Studio agent reader) and §9 (MCP/AGF) are still labelled preview in April 2026. The bootstrap refuses to proceed without explicit acknowledgement.
function Confirm-Feat224PreviewGate {
[CmdletBinding()]
param(
[switch] $AcceptPreview,
[string] $TicketId
)
$previews = @(
'Microsoft.Graph.Beta — /copilotStudio/agents (preview)',
'Microsoft.Graph.Beta — /copilot/features (preview, group-scoped overrides)',
'Microsoft.Graph.Beta — /agents/{id}/tools (Agent Framework, preview)',
'PowerApps Admin — Get-AdminPowerAppEnvironmentFeature (preview parameter set)'
)
if (-not $AcceptPreview) {
return [pscustomobject]@{
Status = 'Pending'
Reason = 'Preview surfaces required. Re-run with -AcceptPreview and a CAB ticket reference.'
Previews = $previews
}
}
if (-not $TicketId) {
return [pscustomobject]@{
Status = 'Anomaly'
Reason = 'Preview accepted without TicketId — defect F2 (no change-management evidence).'
Previews = $previews
}
}
return [pscustomobject]@{
Status = 'Clean'
Previews = $previews
TicketId = $TicketId
}
}
2.2 Shell-host assertion
function Assert-Feat224ShellHost {
[CmdletBinding()]
param()
if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion.Major -lt 7) {
throw "Control 2.24 helpers require PowerShell 7.4+ (pwsh). Detected: $($PSVersionTable.PSVersion). See defect W2 in §0."
}
if ([Environment]::Is64BitProcess -ne $true) {
throw "32-bit process detected. Re-launch pwsh as 64-bit."
}
return [pscustomobject]@{
Status = 'Clean'
PSVersion = $PSVersionTable.PSVersion.ToString()
Process = (Get-Process -Id $PID).ProcessName
}
}
2.3 Cloud-profile resolver
function Resolve-Feat224CloudProfile {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [ValidateSet('Commercial','GCC','GCCHigh','DoD')]
[string] $Cloud
)
$profile = switch ($Cloud) {
'Commercial' { @{
GraphEnvironment = 'Global'
PowerAppsEndpoint = 'prod'
AzureEnvironment = 'AzureCloud'
SovereignRedirect = $false
}}
'GCC' { @{
GraphEnvironment = 'USGov'
PowerAppsEndpoint = 'usgov'
AzureEnvironment = 'AzureUSGovernment'
SovereignRedirect = $true
}}
'GCCHigh' { @{
GraphEnvironment = 'USGovHigh'
PowerAppsEndpoint = 'usgovhigh'
AzureEnvironment = 'AzureUSGovernment'
SovereignRedirect = $true
}}
'DoD' { @{
GraphEnvironment = 'USGovDoD'
PowerAppsEndpoint = 'dod'
AzureEnvironment = 'AzureUSGovernment'
SovereignRedirect = $true
}}
}
$env:PowerAppsEndpoint = $profile.PowerAppsEndpoint
return [pscustomobject]@{
Status = 'Clean'
Cloud = $Cloud
GraphEnvironment = $profile.GraphEnvironment
PowerAppsEndpoint = $profile.PowerAppsEndpoint
AzureEnvironment = $profile.AzureEnvironment
SovereignRedirect = $profile.SovereignRedirect
Reason = if ($profile.SovereignRedirect) {
"Sovereign cloud detected — §11 Invoke-Feat224SovereignRegister will run instead of §§4–9 commercial helpers (defect F8)."
} else { $null }
}
}
See Sovereign Cloud Endpoints (GCC / GCC High / DoD) for the underlying endpoint table.
2.4 Session initializer
function Initialize-Feat224Session {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $TenantId,
[Parameter(Mandatory)] [ValidateSet('Commercial','GCC','GCCHigh','DoD')] [string] $Cloud,
[Parameter(Mandatory)] [string] $JustificationTicket,
[string[]] $GraphScopes = @('CopilotSettings.Read.All','Directory.Read.All','AuditLog.Read.All','CopilotStudio.Read.All','Agent.Read.All','Application.Read.All'),
[switch] $AcceptPreview
)
$shell = Assert-Feat224ShellHost
$preview = Confirm-Feat224PreviewGate -AcceptPreview:$AcceptPreview -TicketId $JustificationTicket
$cp = Resolve-Feat224CloudProfile -Cloud $Cloud
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Connect-MgGraph -TenantId $TenantId -Scopes $GraphScopes -Environment $cp.GraphEnvironment -NoWelcome
if ($cp.AzureEnvironment) {
Connect-AzAccount -Tenant $TenantId -Environment $cp.AzureEnvironment -ErrorAction Stop | Out-Null
}
Add-PowerAppsAccount -Endpoint $cp.PowerAppsEndpoint | Out-Null
return [pscustomobject]@{
Status = if ($preview.Status -eq 'Clean') { 'Clean' } else { $preview.Status }
TenantId = $TenantId
Cloud = $Cloud
SovereignRedirect = $cp.SovereignRedirect
Shell = $shell
PreviewGate = $preview
GraphScopes = $GraphScopes
Reason = if ($preview.Status -ne 'Clean') { $preview.Reason } else { $null }
}
}
2.5 Graph scope verifier
function Test-Feat224GraphScopes {
[CmdletBinding()]
param([Parameter(Mandatory)] [string[]] $Required)
$context = Get-MgContext
if (-not $context) {
return [pscustomobject]@{ Status = 'Error'; Reason = 'No Graph context — run Initialize-Feat224Session.' }
}
$missing = $Required | Where-Object { $_ -notin $context.Scopes }
if ($missing.Count -gt 0) {
return [pscustomobject]@{
Status = 'Anomaly'
Reason = "Missing Graph scopes: $($missing -join ', '). Re-consent required."
Missing = $missing
Granted = $context.Scopes
}
}
return [pscustomobject]@{ Status = 'Clean'; Granted = $context.Scopes }
}
2.6 Paged Graph helper
function Invoke-Feat224PagedQuery {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Uri,
[ValidateSet('v1.0','beta')] [string] $ApiVersion = 'v1.0',
[int] $MaxPages = 50
)
$results = New-Object System.Collections.Generic.List[object]
$next = if ($Uri -like 'http*') { $Uri } else { "https://graph.microsoft.com/$ApiVersion/$($Uri.TrimStart('/'))" }
$page = 0
while ($next -and $page -lt $MaxPages) {
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri $next -ErrorAction Stop
} catch {
return [pscustomobject]@{
Status = 'Error'
Reason = "Graph paged query failed at page $page : $($_.Exception.Message)"
Uri = $next
}
}
if ($resp.value) { $results.AddRange([object[]]$resp.value) }
$next = $resp.'@odata.nextLink'
$page++
}
return [pscustomobject]@{
Status = 'Clean'
Count = $results.Count
Pages = $page
Items = $results.ToArray()
}
}
2.7 Throttle wrapper
function Invoke-Feat224Throttled {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [scriptblock] $Script,
[int] $MaxRetries = 5,
[int] $InitialBackoffMs = 500
)
$attempt = 0
while ($true) {
try {
return & $Script
} catch {
$msg = $_.Exception.Message
$isThrottle = $msg -match '429|Too Many Requests|throttl'
if (-not $isThrottle -or $attempt -ge $MaxRetries) {
throw
}
$delay = $InitialBackoffMs * [math]::Pow(2, $attempt)
Start-Sleep -Milliseconds $delay
$attempt++
}
}
}
2.8 Configuration JSON schema
feat224.config.json lives under evidence\config\ and is hash-pinned in every manifest.
{
"$schema": "https://fsi-agentgov/schemas/feat224-config-v1.json",
"tenantId": "00000000-0000-0000-0000-000000000000",
"cloud": "Commercial",
"evidenceRoot": "C:\\fsi-evidence\\2.24",
"retentionYears": 7,
"siem": {
"dceUri": "https://feat224-dce.eastus-1.ingest.monitor.azure.com",
"dcrImmutableId": "dcr-00000000000000000000000000000000",
"streamName": "Custom-Feat224ChangeEvent_CL"
},
"catalog": {
"dataverseEnvironmentUrl": "https://orgxxxxxxxx.crm.dynamics.com",
"tableLogicalName": "fsi_featurecatalog"
},
"siemCanaryCorrelationId": "FEAT224-CANARY",
"approvedRoles": ["AIAdministrator","PowerPlatformAdmin","AIGovernanceLead","ComplianceOfficer","ChangeManagementTeam","AgentOwner"]
}
2.9 Evidence manifest helper
function New-Feat224EvidenceManifest {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $RunId,
[Parameter(Mandatory)] [object[]] $Findings,
[Parameter(Mandatory)] [string] $EvidenceRoot,
[string] $PreviousManifestPath
)
$manifestDir = Join-Path $EvidenceRoot $RunId
$manifestPath = Join-Path $manifestDir 'manifest.json'
if ($PSCmdlet.ShouldProcess($manifestPath, 'Write evidence manifest')) {
New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
$prevHash = if ($PreviousManifestPath -and (Test-Path $PreviousManifestPath)) {
(Get-FileHash -Path $PreviousManifestPath -Algorithm SHA256).Hash
} else { 'GENESIS' }
$manifest = [ordered]@{
schemaVersion = 'feat224-manifest-v1'
runId = $RunId
generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
generatedBy = (Get-MgContext).Account
previousHash = $prevHash
regulatoryAnchors = @('SOX-302','SOX-404','FINRA-3110','FINRA-4511','FINRA-25-07','FedSR-11-7','OCC-2011-12','FFIEC-IT-RM','GLBA-501b','SEC-RegSCI')
findings = $Findings
}
$json = $manifest | ConvertTo-Json -Depth 12
Set-Content -Path $manifestPath -Value $json -Encoding UTF8
$hash = (Get-FileHash -Path $manifestPath -Algorithm SHA256).Hash
return [pscustomobject]@{
Status = 'Clean'
ManifestPath = $manifestPath
ManifestHash = $hash
PreviousHash = $prevHash
FindingCount = $Findings.Count
}
}
}
Defect F11 mitigation. Always pass
-PreviousManifestPathwhen chaining runs. A broken hash chain is reported by §10's evidence exporter asStatus = 'Anomaly'with reason"F11: hash chain discontinuity".
§3 — Dataverse Feature Catalog table provisioning
The Feature Catalog is the authoritative list of every Microsoft 365 / Power Platform / Copilot Studio / Agent Framework / MCP feature an organization has approved (or explicitly disallowed). It is stored in a Dataverse table, fsi_featurecatalog, in a dedicated Governance environment (Tier 0 per Control 2.2). The table must exist before any §§4–9 helper runs because every observation is reconciled against a catalog row.
3.1 Table schema
| Logical name | Display name | Type | Required | Notes |
|---|---|---|---|---|
fsi_featurename |
Feature Name | Text(200) | Yes | Vendor-supplied identifier (e.g., CopilotPages, M365Copilot.Generative.Answers) |
fsi_surface |
Surface | Choice | Yes | M365Hub, PPAC, CopilotStudio, AgentFramework, MCP, DLP |
fsi_cloudscope |
Cloud Scope | Choice | Yes | Commercial, GCC, GCCHigh, DoD, All |
fsi_zonestatus |
Zone Status | Choice | Yes | Zone1Personal, Zone2Team, Zone3Enterprise, Disallowed |
fsi_approvaldate |
Approval Date | Date | Yes | F9: must be ≥ vendor GA date |
fsi_changeticket |
Change Ticket | Text(50) | Yes | ServiceNow / Jira reference |
fsi_expirationdate |
Expiration Date | Date | Yes | F1: re-attestation required by this date |
fsi_riskrating |
Risk Rating | Choice | Yes | Low, Moderate, High, Critical |
fsi_mrmreviewid |
MRM Review ID | Text(100) | Conditional | Required when fsi_riskrating in {High, Critical} per Control 2.6 |
fsi_supervisionscope |
Supervision Scope | Text(500) | Conditional | Required when feature affects user-facing output per Control 2.12 |
fsi_lastobservedutc |
Last Observed UTC | DateTime | No | Stamped by Compare-Feat224ZoneCompliance |
3.2 Idempotent provisioner
function Deploy-Feat224FeatureCatalog {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $DataverseEnvironmentUrl,
[string] $SolutionUniqueName = 'fsi_agentgov'
)
Import-Module Microsoft.Xrm.Data.PowerShell -ErrorAction Stop
$conn = Connect-CrmOnline -ServerUrl $DataverseEnvironmentUrl -OAuth -ErrorAction Stop
# F-defect: Check whether table already exists before attempting create
$existing = Get-CrmEntityMetadata -conn $conn -EntityLogicalName 'fsi_featurecatalog' -ErrorAction SilentlyContinue
if ($existing) {
$columnsPresent = $existing.Attributes | Where-Object { $_.LogicalName -like 'fsi_*' } | Select-Object -ExpandProperty LogicalName
$required = @('fsi_featurename','fsi_surface','fsi_cloudscope','fsi_zonestatus','fsi_approvaldate','fsi_changeticket','fsi_expirationdate','fsi_riskrating')
$missingCols = $required | Where-Object { $_ -notin $columnsPresent }
if ($missingCols.Count -gt 0) {
return [pscustomobject]@{
Status = 'Anomaly'
Reason = "Table exists but missing required columns: $($missingCols -join ', '). Run with -WhatIf:`$false to add them, or escalate per Control 2.2."
MissingCols = $missingCols
}
}
return [pscustomobject]@{
Status = 'Clean'
Reason = 'Table fsi_featurecatalog already provisioned and schema-complete.'
Columns = $columnsPresent
}
}
if (-not $PSCmdlet.ShouldProcess($DataverseEnvironmentUrl, 'Create fsi_featurecatalog')) {
return [pscustomobject]@{ Status = 'Pending'; Reason = 'WhatIf — no provisioning attempted.' }
}
# Table + columns creation via Web API (abbreviated — full body in evidence repo)
$tableMeta = @{
'@odata.type' = 'Microsoft.Dynamics.CRM.EntityMetadata'
SchemaName = 'fsi_FeatureCatalog'
DisplayName = @{ LocalizedLabels = @(@{ Label = 'Feature Catalog'; LanguageCode = 1033 }) }
DisplayCollectionName = @{ LocalizedLabels = @(@{ Label = 'Feature Catalogs'; LanguageCode = 1033 }) }
OwnershipType = 'UserOwned'
HasActivities = $false
HasNotes = $true
Attributes = @(
@{ '@odata.type' = 'Microsoft.Dynamics.CRM.StringAttributeMetadata'; SchemaName = 'fsi_FeatureName'; MaxLength = 200; RequiredLevel = @{ Value = 'ApplicationRequired' }; DisplayName = @{ LocalizedLabels = @(@{ Label = 'Feature Name'; LanguageCode = 1033 }) }; IsPrimaryName = $true }
)
}
try {
$resp = Invoke-CrmWebApiRequest -conn $conn -Method POST -Path 'EntityDefinitions' -Body ($tableMeta | ConvertTo-Json -Depth 10)
} catch {
return [pscustomobject]@{ Status = 'Error'; Reason = "Table creation failed: $($_.Exception.Message)" }
}
return [pscustomobject]@{
Status = 'Clean'
Reason = $null
Created = $true
Solution = $SolutionUniqueName
TableUrl = "$DataverseEnvironmentUrl/api/data/v9.2/EntityDefinitions(LogicalName='fsi_featurecatalog')"
}
}
3.3 Catalog-row validator (F9 catcher)
function Test-Feat224CatalogRow {
[CmdletBinding()]
param([Parameter(Mandatory)] [hashtable] $Row, [Parameter(Mandatory)] [hashtable] $VendorGaDates)
$issues = @()
if (-not $Row.fsi_featurename) { $issues += 'Missing FeatureName' }
if (-not $Row.fsi_changeticket) { $issues += 'F2: Missing ChangeTicket' }
if (-not $Row.fsi_approvaldate) { $issues += 'Missing ApprovalDate' }
if ($Row.fsi_expirationdate -and $Row.fsi_expirationdate -lt (Get-Date)) {
$issues += 'F1: Expired catalog row'
}
$ga = $VendorGaDates[$Row.fsi_featurename]
if ($ga -and $Row.fsi_approvaldate -lt $ga) {
$issues += "F9: ApprovalDate $($Row.fsi_approvaldate.ToString('yyyy-MM-dd')) precedes vendor GA $($ga.ToString('yyyy-MM-dd'))"
}
if ($Row.fsi_riskrating -in @('High','Critical') -and -not $Row.fsi_mrmreviewid) {
$issues += 'Missing MRM Review ID for High/Critical risk rating (Control 2.6)'
}
if ($issues.Count -gt 0) {
return [pscustomobject]@{
Status = 'Anomaly'
Reason = $issues -join '; '
Row = $Row
}
}
return [pscustomobject]@{ Status = 'Clean'; Row = $Row }
}
3.4 Pester (CATALOG)
Describe 'Feat224 — CATALOG namespace' -Tag CATALOG {
Context 'fsi_featurecatalog table' {
It 'exists in the Governance environment' {
$r = Deploy-Feat224FeatureCatalog -DataverseEnvironmentUrl $env:FEAT224_GOV_URL -WhatIf
$r.Status | Should -BeIn @('Clean','Pending')
}
It 'has all eight required columns' {
$r = Deploy-Feat224FeatureCatalog -DataverseEnvironmentUrl $env:FEAT224_GOV_URL -WhatIf
if ($r.Status -eq 'Clean') {
($r.Columns) | Should -Contain 'fsi_changeticket'
}
}
}
Context 'Catalog-row validation' {
It 'flags F1 (expired)' {
$row = @{ fsi_featurename='Test'; fsi_changeticket='CHG1'; fsi_approvaldate=(Get-Date).AddYears(-2); fsi_expirationdate=(Get-Date).AddDays(-1); fsi_riskrating='Low' }
(Test-Feat224CatalogRow -Row $row -VendorGaDates @{}).Reason | Should -Match 'F1'
}
It 'flags F2 (missing ChangeTicket)' {
$row = @{ fsi_featurename='Test'; fsi_approvaldate=(Get-Date); fsi_riskrating='Low' }
(Test-Feat224CatalogRow -Row $row -VendorGaDates @{}).Reason | Should -Match 'F2'
}
It 'flags F9 (approval before GA)' {
$row = @{ fsi_featurename='X'; fsi_changeticket='CHG2'; fsi_approvaldate=(Get-Date '2024-01-01'); fsi_riskrating='Low' }
$ga = @{ X = (Get-Date '2024-06-01') }
(Test-Feat224CatalogRow -Row $row -VendorGaDates $ga).Reason | Should -Match 'F9'
}
}
}
§4 — Microsoft 365 admin center / Copilot hub reader
The M365 admin center surfaces tenant-wide Copilot toggles (Copilot Pages, Copilot Search, declarative-agent allow-list, M365 Hub orchestration). This helper extracts them via Graph and the admin-center API, then reconciles tenant-wide vs. group-scoped overrides — defect F5.
4.1 Helper
function Get-Feat224M365FeatureState {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $TenantId,
[hashtable] $CatalogIndex # keyed by fsi_featurename
)
$scopeCheck = Test-Feat224GraphScopes -Required @('CopilotSettings.Read.All','Directory.Read.All')
if ($scopeCheck.Status -ne 'Clean') { return $scopeCheck }
$findings = New-Object System.Collections.Generic.List[object]
# Tenant-wide Copilot settings (v1.0)
try {
$tenantWide = Invoke-Feat224Throttled { Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/copilot/settings' }
} catch {
return [pscustomobject]@{ Status='Error'; Reason="copilot/settings failed: $($_.Exception.Message)" }
}
# Group-scoped overrides (beta)
$overrides = Invoke-Feat224PagedQuery -Uri 'copilot/features?$expand=assignments' -ApiVersion 'beta'
if ($overrides.Status -ne 'Clean') { return $overrides }
foreach ($feature in $tenantWide.features) {
$name = $feature.id
$tenantOn = [bool]$feature.enabled
$catalog = $CatalogIndex[$name]
$override = $overrides.Items | Where-Object { $_.id -eq $name -and $_.assignments.Count -gt 0 }
$status = 'Clean'
$reasons = @()
if (-not $catalog) {
$status = 'Anomaly'
$reasons += "F7-adjacent: feature '$name' enabled in tenant but absent from Feature Catalog"
} elseif ($catalog.fsi_zonestatus -eq 'Disallowed' -and $tenantOn) {
$status = 'Anomaly'
$reasons += "Catalog marks '$name' Disallowed but tenant has it ON"
}
if ($override) {
$status = 'Anomaly'
$reasons += "F5: tenant-wide=$tenantOn but group-scoped overrides exist (assignments=$($override.assignments.Count))"
}
$findings.Add([pscustomobject]@{
Status = $status
Surface = 'M365Hub'
FeatureName = $name
TenantEnabled = $tenantOn
CatalogZone = $catalog?.fsi_zonestatus
CatalogTicket = $catalog?.fsi_changeticket
HasOverride = [bool]$override
Reason = if ($reasons) { $reasons -join '; ' } else { $null }
})
}
$rollup = if ($findings | Where-Object Status -eq 'Anomaly') { 'Anomaly' } else { 'Clean' }
return [pscustomobject]@{
Status = $rollup
Surface = 'M365Hub'
FeatureCount = $findings.Count
Findings = $findings.ToArray()
Reason = if ($rollup -eq 'Anomaly') { ($findings | Where-Object Status -eq 'Anomaly').Reason -join ' | ' } else { $null }
}
}
4.2 Pester (M365HUB)
Describe 'Feat224 — M365HUB namespace' -Tag M365HUB {
BeforeAll {
$script:catalog = @{
'CopilotPages' = @{ fsi_featurename='CopilotPages'; fsi_zonestatus='Zone3Enterprise'; fsi_changeticket='CHG-001' }
}
}
It 'returns Clean when tenant matches catalog' {
Mock Invoke-MgGraphRequest { @{ features = @(@{ id='CopilotPages'; enabled=$true }) } }
Mock Invoke-Feat224PagedQuery { [pscustomobject]@{ Status='Clean'; Items=@() } }
Mock Test-Feat224GraphScopes { [pscustomobject]@{ Status='Clean' } }
(Get-Feat224M365FeatureState -TenantId 'x' -CatalogIndex $script:catalog).Status | Should -Be 'Clean'
}
It 'flags F5 when group overrides exist' {
Mock Invoke-MgGraphRequest { @{ features = @(@{ id='CopilotPages'; enabled=$false }) } }
Mock Invoke-Feat224PagedQuery { [pscustomobject]@{ Status='Clean'; Items=@(@{ id='CopilotPages'; assignments=@(@{groupId='g1'}) }) } }
Mock Test-Feat224GraphScopes { [pscustomobject]@{ Status='Clean' } }
$r = Get-Feat224M365FeatureState -TenantId 'x' -CatalogIndex $script:catalog
$r.Status | Should -Be 'Anomaly'
$r.Reason | Should -Match 'F5'
}
}
§5 — Power Platform Admin Center reader
PPAC exposes both environment-level Copilot/agent feature flags and the Copilot Hub preview-feature opt-ins. This helper enumerates every environment, classifies each by tier (Default / Productivity / Standard / Sandbox / Production-Tier1 per Control 2.2), reads per-environment feature states, and emits one finding per environment × feature.
5.1 Helper
function Get-Feat224PpacFeatureState {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [hashtable] $CatalogIndex,
[string[]] $EnvironmentSkuFilter = @('Default','Production','Sandbox','Trial')
)
try {
$envs = Get-AdminPowerAppEnvironment | Where-Object { $_.EnvironmentType -in $EnvironmentSkuFilter }
} catch {
return [pscustomobject]@{ Status='Error'; Reason="Get-AdminPowerAppEnvironment failed: $($_.Exception.Message)" }
}
$findings = New-Object System.Collections.Generic.List[object]
foreach ($env in $envs) {
$isDefault = ($env.EnvironmentType -eq 'Default')
try {
$features = Invoke-Feat224Throttled {
Get-AdminPowerAppEnvironmentFeature -EnvironmentName $env.EnvironmentName -ErrorAction Stop
}
} catch {
$findings.Add([pscustomobject]@{
Status = 'Error'
Surface = 'PPAC'
EnvironmentId = $env.EnvironmentName
Reason = "Feature read failed: $($_.Exception.Message)"
})
continue
}
foreach ($f in $features) {
$name = $f.FeatureName
$on = [bool]$f.Enabled
$catalog = $CatalogIndex[$name]
$status = 'Clean'
$reasons = @()
if (-not $catalog) {
$status = 'Anomaly'
$reasons += "F7-adjacent: feature '$name' enabled in environment but absent from catalog"
}
if ($on -and $isDefault -and $catalog -and $catalog.fsi_zonestatus -ne 'Zone1Personal') {
$status = 'Anomaly'
$reasons += "F6: feature '$name' active in Default environment but catalog scope is $($catalog.fsi_zonestatus)"
}
if ($catalog -and $catalog.fsi_zonestatus -eq 'Disallowed' -and $on) {
$status = 'Anomaly'
$reasons += "Disallowed feature enabled"
}
$findings.Add([pscustomobject]@{
Status = $status
Surface = 'PPAC'
EnvironmentId = $env.EnvironmentName
EnvironmentName = $env.DisplayName
EnvironmentType = $env.EnvironmentType
FeatureName = $name
Enabled = $on
CatalogZone = $catalog?.fsi_zonestatus
Reason = if ($reasons) { $reasons -join '; ' } else { $null }
})
}
}
$rollup = if ($findings | Where-Object Status -in 'Anomaly','Error') { 'Anomaly' } else { 'Clean' }
return [pscustomobject]@{
Status = $rollup
Surface = 'PPAC'
EnvironmentCount = $envs.Count
FindingCount = $findings.Count
Findings = $findings.ToArray()
Reason = if ($rollup -eq 'Anomaly') { 'See Findings for per-environment detail.' } else { $null }
}
}
5.2 Pester (PPAC, ENV)
Describe 'Feat224 — PPAC namespace' -Tag PPAC,ENV {
BeforeAll {
$script:catalog = @{
'CopilotInModelDriven' = @{ fsi_featurename='CopilotInModelDriven'; fsi_zonestatus='Zone2Team' }
}
}
It 'flags F6 when feature is on in Default environment' {
Mock Get-AdminPowerAppEnvironment { @(@{ EnvironmentName='env-default'; DisplayName='Default'; EnvironmentType='Default' }) }
Mock Get-AdminPowerAppEnvironmentFeature { @(@{ FeatureName='CopilotInModelDriven'; Enabled=$true }) }
$r = Get-Feat224PpacFeatureState -CatalogIndex $script:catalog
$r.Status | Should -Be 'Anomaly'
($r.Findings | Where-Object Reason -match 'F6').Count | Should -BeGreaterThan 0
}
It 'returns Clean when feature is in a Production environment with matching catalog zone' {
Mock Get-AdminPowerAppEnvironment { @(@{ EnvironmentName='env-team'; DisplayName='TeamProd'; EnvironmentType='Production' }) }
Mock Get-AdminPowerAppEnvironmentFeature { @(@{ FeatureName='CopilotInModelDriven'; Enabled=$true }) }
(Get-Feat224PpacFeatureState -CatalogIndex $script:catalog).Status | Should -Be 'Clean'
}
}
§6 — Copilot Studio agent feature reader
Per-agent overrides can either strengthen (disable a feature the tenant allows) or weaken (re-enable something disabled at higher scope through a connector or MCP path). The strengthening path is defect F10 — supervision (Control 2.12) is monitoring something that has been silently turned off.
6.1 Helper
function Get-Feat224AgentFeatureState {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [hashtable] $CatalogIndex,
[Parameter(Mandatory)] [hashtable] $TenantFeatureIndex, # name -> bool
[string[]] $EnvironmentIds
)
$findings = New-Object System.Collections.Generic.List[object]
if (-not $EnvironmentIds) {
$EnvironmentIds = (Get-AdminPowerAppEnvironment | Where-Object EnvironmentType -in 'Production','Sandbox').EnvironmentName
}
foreach ($envId in $EnvironmentIds) {
$agents = Invoke-Feat224PagedQuery -Uri "copilotStudio/environments/$envId/agents" -ApiVersion 'beta'
if ($agents.Status -ne 'Clean') {
$findings.Add([pscustomobject]@{ Status='Error'; Surface='CopilotStudio'; EnvironmentId=$envId; Reason=$agents.Reason })
continue
}
foreach ($agent in $agents.Items) {
foreach ($featureName in $agent.featureOverrides.Keys) {
$agentVal = [bool]$agent.featureOverrides[$featureName]
$tenantVal = [bool]$TenantFeatureIndex[$featureName]
$catalog = $CatalogIndex[$featureName]
$status = 'Clean'
$reasons = @()
if ($tenantVal -and -not $agentVal) {
$status = 'Anomaly'
$reasons += "F10: agent '$($agent.displayName)' overrides '$featureName' OFF while tenant has it ON; supervision feed (Control 2.12) may have a blind spot"
}
if (-not $tenantVal -and $agentVal) {
$status = 'Anomaly'
$reasons += "Agent re-enables '$featureName' that tenant has OFF — investigate connector/MCP path"
}
if ($catalog -and $catalog.fsi_zonestatus -eq 'Disallowed' -and $agentVal) {
$status = 'Anomaly'
$reasons += 'Disallowed feature active on agent'
}
$findings.Add([pscustomobject]@{
Status = $status
Surface = 'CopilotStudio'
EnvironmentId = $envId
AgentId = $agent.id
AgentName = $agent.displayName
FeatureName = $featureName
AgentValue = $agentVal
TenantValue = $tenantVal
Reason = if ($reasons) { $reasons -join '; ' } else { $null }
})
}
}
}
$rollup = if ($findings | Where-Object Status -in 'Anomaly','Error') { 'Anomaly' } else { 'Clean' }
return [pscustomobject]@{
Status = $rollup
Surface = 'CopilotStudio'
Findings = $findings.ToArray()
Reason = if ($rollup -eq 'Anomaly') { 'See Findings.' } else { $null }
}
}
6.2 Pester (AGENT)
Describe 'Feat224 — AGENT namespace' -Tag AGENT {
It 'flags F10 (agent disables tenant-enabled feature)' {
Mock Invoke-Feat224PagedQuery {
[pscustomobject]@{ Status='Clean'; Items=@(@{ id='a1'; displayName='Demo'; featureOverrides=@{ 'GenAns'=$false } }) }
}
Mock Get-AdminPowerAppEnvironment { @(@{ EnvironmentName='e1'; EnvironmentType='Production' }) }
$r = Get-Feat224AgentFeatureState -CatalogIndex @{} -TenantFeatureIndex @{ 'GenAns'=$true } -EnvironmentIds @('e1')
($r.Findings | Where-Object Reason -match 'F10').Count | Should -BeGreaterThan 0
}
}
§7 — DLP policy ↔ Feature catalog correlation
DLP policies (Control 1.4) and feature toggles must be evaluated together: blocking a connector at the platform layer accomplishes nothing if the same capability can reach the model via an MCP server or Agent Framework workflow tool — defect F4.
7.1 Helper
function Compare-Feat224DlpAlignment {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [hashtable] $CatalogIndex,
[Parameter(Mandatory)] [object[]] $McpInventory # output of Get-Feat224McpAgfState
)
try {
$policies = Get-AdminDlpPolicy -ErrorAction Stop
} catch {
return [pscustomobject]@{ Status='Error'; Reason="Get-AdminDlpPolicy failed: $($_.Exception.Message)" }
}
$blockedConnectors = $policies |
ForEach-Object { $_.ConnectorGroups | Where-Object Classification -eq 'Blocked' } |
ForEach-Object { $_.Connectors } |
Select-Object -ExpandProperty Id -Unique
$findings = New-Object System.Collections.Generic.List[object]
foreach ($mcp in $McpInventory) {
$shadows = $blockedConnectors | Where-Object { $mcp.CapabilityKeywords -contains $_ -or $mcp.ConnectorEquivalents -contains $_ }
if ($shadows) {
$findings.Add([pscustomobject]@{
Status = 'Anomaly'
Surface = 'DLP'
McpServer = $mcp.ServerName
AgentId = $mcp.AgentId
Shadows = $shadows
Reason = "F4: MCP server '$($mcp.ServerName)' shadows DLP-blocked connectors: $($shadows -join ', ')"
})
}
}
foreach ($pol in $policies) {
if (-not $CatalogIndex["DLP:$($pol.PolicyName)"]) {
$findings.Add([pscustomobject]@{
Status = 'Anomaly'
Surface = 'DLP'
PolicyName = $pol.PolicyName
Reason = "DLP policy '$($pol.PolicyName)' has no catalog row (Control 1.4 governance gap)"
})
}
}
$rollup = if ($findings.Count -gt 0) { 'Anomaly' } else { 'Clean' }
return [pscustomobject]@{
Status = $rollup
Surface = 'DLP'
BlockedConnectors = $blockedConnectors
Findings = $findings.ToArray()
Reason = if ($rollup -eq 'Anomaly') { 'DLP / MCP shadowing or uncatalogued policies — see Findings.' } else { $null }
}
}
7.2 Pester (DLP)
Describe 'Feat224 — DLP namespace' -Tag DLP {
It 'flags F4 when MCP server shadows a blocked connector' {
Mock Get-AdminDlpPolicy {
@(@{ PolicyName='ProdDLP'; ConnectorGroups=@(@{ Classification='Blocked'; Connectors=@(@{ Id='shared_sql' }) }) })
}
$mcp = @([pscustomobject]@{ ServerName='sqlbridge'; AgentId='a1'; CapabilityKeywords=@('shared_sql'); ConnectorEquivalents=@() })
$r = Compare-Feat224DlpAlignment -CatalogIndex @{ 'DLP:ProdDLP' = @{} } -McpInventory $mcp
$r.Status | Should -Be 'Anomaly'
($r.Findings | Where-Object Reason -match 'F4').Count | Should -Be 1
}
}
§8 — Zone (1/2/3) compliance diff
This is the central reconciliation helper. It joins (a) the M365 hub findings (§4), (b) the PPAC findings (§5), (c) per-agent findings (§6), and (d) the catalog itself, and emits one of: Clean, Anomaly, Pending (catalog row exists but no observation yet — defect F13), NotApplicable, or Error. It also surfaces defect F15 (UnusedEnabled) — features the tenant has enabled but no observed agent uses.
8.1 Helper
function Compare-Feat224ZoneCompliance {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [hashtable] $CatalogIndex,
[Parameter(Mandatory)] [object[]] $M365Findings,
[Parameter(Mandatory)] [object[]] $PpacFindings,
[Parameter(Mandatory)] [object[]] $AgentFindings
)
$observedFeatures = @{}
foreach ($f in @($M365Findings + $PpacFindings + $AgentFindings)) {
if ($f.FeatureName) {
if (-not $observedFeatures[$f.FeatureName]) { $observedFeatures[$f.FeatureName] = New-Object System.Collections.Generic.List[object] }
$observedFeatures[$f.FeatureName].Add($f)
}
}
$findings = New-Object System.Collections.Generic.List[object]
# F13: Catalog rows with no observation
foreach ($name in $CatalogIndex.Keys) {
if (-not $observedFeatures.ContainsKey($name)) {
$findings.Add([pscustomobject]@{
Status = 'Pending'
FeatureName = $name
Reason = 'F13: catalog row present but no tenant observation yet — re-run after next admin-center sync (typical lag 24h)'
})
}
}
# F15: enabled but unused
foreach ($name in $observedFeatures.Keys) {
$obs = $observedFeatures[$name]
$tenantOn = ($obs | Where-Object { $_.Surface -eq 'M365Hub' -and $_.TenantEnabled }).Count -gt 0
$agentUse = ($obs | Where-Object { $_.Surface -eq 'CopilotStudio' -and $_.AgentValue }).Count
if ($tenantOn -and $agentUse -eq 0) {
$findings.Add([pscustomobject]@{
Status = 'Anomaly'
FeatureName = $name
Reason = 'F15: feature enabled tenant-wide but no agent uses it (latent attack surface; SR 11-7 inventory expectation)'
})
}
}
# F1: expired catalog rows
foreach ($name in $CatalogIndex.Keys) {
$row = $CatalogIndex[$name]
if ($row.fsi_expirationdate -and $row.fsi_expirationdate -lt (Get-Date)) {
$findings.Add([pscustomobject]@{
Status = 'Anomaly'
FeatureName = $name
Reason = "F1: catalog ExpirationDate $($row.fsi_expirationdate.ToString('yyyy-MM-dd')) has passed; re-attestation required"
})
}
}
# F3: zone elevation — agent in Zone 3 but catalog allows only Zone 2
foreach ($af in $AgentFindings | Where-Object { $_.Surface -eq 'CopilotStudio' -and $_.AgentValue }) {
$cat = $CatalogIndex[$af.FeatureName]
if ($cat -and $cat.fsi_zonestatus -eq 'Zone2Team') {
$envIsTier1 = $af.EnvironmentId -match 'tier1|prod'
if ($envIsTier1) {
$findings.Add([pscustomobject]@{
Status = 'Anomaly'
FeatureName = $af.FeatureName
AgentId = $af.AgentId
Reason = "F3: agent uses '$($af.FeatureName)' in a Zone 3 (Tier 1) environment but catalog scope is Zone 2 — re-approve per Control 2.2"
})
}
}
}
$rollup = if ($findings | Where-Object Status -eq 'Anomaly') { 'Anomaly' }
elseif ($findings | Where-Object Status -eq 'Pending') { 'Pending' }
else { 'Clean' }
return [pscustomobject]@{
Status = $rollup
Findings = $findings.ToArray()
Reason = if ($rollup -ne 'Clean') { 'See Findings.' } else { $null }
}
}
8.2 Pester (ZONE)
Describe 'Feat224 — ZONE namespace' -Tag ZONE {
It 'flags F13 when catalog has rows but no observations' {
$r = Compare-Feat224ZoneCompliance -CatalogIndex @{ 'X' = @{} } -M365Findings @() -PpacFindings @() -AgentFindings @()
($r.Findings | Where-Object Reason -match 'F13').Count | Should -BeGreaterThan 0
}
It 'flags F15 when feature is on but unused by agents' {
$r = Compare-Feat224ZoneCompliance -CatalogIndex @{ 'X' = @{} } `
-M365Findings @([pscustomobject]@{ Surface='M365Hub'; FeatureName='X'; TenantEnabled=$true }) `
-PpacFindings @() `
-AgentFindings @()
($r.Findings | Where-Object Reason -match 'F15').Count | Should -BeGreaterThan 0
}
It 'flags F3 when agent uses a Zone 2 feature in a tier-1 environment' {
$cat = @{ 'GenAns' = @{ fsi_zonestatus='Zone2Team' } }
$agf = @([pscustomobject]@{ Surface='CopilotStudio'; FeatureName='GenAns'; AgentValue=$true; AgentId='a1'; EnvironmentId='env-tier1-prod' })
$r = Compare-Feat224ZoneCompliance -CatalogIndex $cat -M365Findings @() -PpacFindings @() -AgentFindings $agf
($r.Findings | Where-Object Reason -match 'F3').Count | Should -BeGreaterThan 0
}
}
§9 — MCP connector + Agent Framework feature-flag enumeration
MCP servers and Agent Framework workflow tools are not surfaced in the M365 admin center as of April 2026 — defect F7. They must be enumerated directly from the agent definition (Copilot Studio export) and from the Agent Framework beta endpoint.
9.1 Helper
function Get-Feat224McpAgfState {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [hashtable] $CatalogIndex,
[string[]] $EnvironmentIds
)
$findings = New-Object System.Collections.Generic.List[object]
$mcpInventory = New-Object System.Collections.Generic.List[object]
if (-not $EnvironmentIds) {
$EnvironmentIds = (Get-AdminPowerAppEnvironment | Where-Object EnvironmentType -in 'Production','Sandbox').EnvironmentName
}
foreach ($envId in $EnvironmentIds) {
# MCP servers via Copilot Studio agent export
$agents = Invoke-Feat224PagedQuery -Uri "copilotStudio/environments/$envId/agents?`$expand=tools,mcpServers" -ApiVersion 'beta'
if ($agents.Status -ne 'Clean') {
$findings.Add([pscustomobject]@{ Status='Error'; Surface='MCP'; EnvironmentId=$envId; Reason=$agents.Reason })
continue
}
foreach ($agent in $agents.Items) {
foreach ($mcp in @($agent.mcpServers)) {
$catKey = "MCP:$($mcp.name)"
$cat = $CatalogIndex[$catKey]
$status = if ($cat) { 'Clean' } else { 'Anomaly' }
$reason = if (-not $cat) { "F7: MCP server '$($mcp.name)' attached to agent '$($agent.displayName)' is not in catalog (key '$catKey')" } else { $null }
$mcpInventory.Add([pscustomobject]@{
ServerName = $mcp.name
AgentId = $agent.id
EnvironmentId = $envId
CapabilityKeywords = @($mcp.capabilities)
ConnectorEquivalents = @($mcp.connectorEquivalents)
})
$findings.Add([pscustomobject]@{
Status = $status
Surface = 'MCP'
EnvironmentId = $envId
AgentId = $agent.id
AgentName = $agent.displayName
McpServer = $mcp.name
Capabilities = $mcp.capabilities
Reason = $reason
})
}
# AGF workflow tools
foreach ($tool in @($agent.tools | Where-Object { $_.kind -eq 'workflow' })) {
$catKey = "AGF:$($tool.id)"
$cat = $CatalogIndex[$catKey]
$status = if ($cat) { 'Clean' } else { 'Anomaly' }
$reason = if (-not $cat) { "F7: Agent Framework workflow tool '$($tool.id)' on agent '$($agent.displayName)' is not in catalog" } else { $null }
$findings.Add([pscustomobject]@{
Status = $status
Surface = 'AgentFramework'
EnvironmentId = $envId
AgentId = $agent.id
AgentName = $agent.displayName
ToolId = $tool.id
Reason = $reason
})
}
}
}
$rollup = if ($findings | Where-Object Status -in 'Anomaly','Error') { 'Anomaly' } else { 'Clean' }
return [pscustomobject]@{
Status = $rollup
Surface = 'MCP+AGF'
Findings = $findings.ToArray()
McpInventory = $mcpInventory.ToArray()
Reason = if ($rollup -ne 'Clean') { 'Uncatalogued MCP servers or AGF tools — see Findings.' } else { $null }
}
}
9.2 Pester (MCP, AGF)
Describe 'Feat224 — MCP + AGF namespaces' -Tag MCP,AGF {
It 'flags F7 for uncatalogued MCP server' {
Mock Invoke-Feat224PagedQuery {
[pscustomobject]@{ Status='Clean'; Items=@(@{
id='a1'; displayName='Demo';
mcpServers=@(@{ name='unknown-mcp'; capabilities=@('sql.query'); connectorEquivalents=@() });
tools=@()
}) }
}
Mock Get-AdminPowerAppEnvironment { @(@{ EnvironmentName='e1'; EnvironmentType='Production' }) }
$r = Get-Feat224McpAgfState -CatalogIndex @{}
($r.Findings | Where-Object Reason -match 'F7').Count | Should -BeGreaterThan 0
}
}
§10 — Change-management evidence exporter
Every observation in §§4–9 must be reconcilable to a Change Management ticket — defect F2. This helper emits a forward-and-reverse trail (catalog → ticket → audit log entry → admin actor) plus a segregation-of-duties check for defect F14.
10.1 Helper
function Export-Feat224ChangeEvidence {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [object[]] $AllFindings,
[Parameter(Mandatory)] [hashtable] $CatalogIndex,
[Parameter(Mandatory)] [string] $EvidenceRoot,
[Parameter(Mandatory)] [string] $RunId,
[string] $PreviousManifestPath,
[datetime] $LookbackStart = (Get-Date).AddDays(-30),
[datetime] $LookbackEnd = (Get-Date)
)
$audit = Invoke-Feat224PagedQuery `
-Uri "auditLogs/directoryAudits?`$filter=activityDateTime ge $($LookbackStart.ToString('o')) and activityDateTime le $($LookbackEnd.ToString('o')) and category eq 'AgentGovernance'" `
-ApiVersion 'beta'
if ($audit.Status -ne 'Clean') {
return [pscustomobject]@{ Status='Error'; Reason="Audit pull failed: $($audit.Reason)" }
}
$changeFindings = New-Object System.Collections.Generic.List[object]
foreach ($f in $AllFindings | Where-Object FeatureName) {
$cat = $CatalogIndex[$f.FeatureName]
if (-not $cat) { continue }
$ticket = $cat.fsi_changeticket
if (-not $ticket) {
$changeFindings.Add([pscustomobject]@{
Status = 'Anomaly'; FeatureName = $f.FeatureName
Reason = 'F2: catalog row has no ChangeTicket — SOX-404 / FINRA 4511 evidence trail missing'
})
continue
}
$auditMatches = $audit.Items | Where-Object { ($_.additionalDetails.value -join ' ') -match [regex]::Escape($ticket) }
if (-not $auditMatches) {
$changeFindings.Add([pscustomobject]@{
Status = 'Anomaly'; FeatureName = $f.FeatureName; Ticket = $ticket
Reason = "Catalog references ticket '$ticket' but no matching audit record in lookback window"
})
continue
}
# F14: dual-control / segregation-of-duties
$actors = $auditMatches.initiatedBy.user.userPrincipalName | Select-Object -Unique
$requesterFromTicket = $cat.fsi_requesterupn
if ($requesterFromTicket -and ($actors -contains $requesterFromTicket)) {
$changeFindings.Add([pscustomobject]@{
Status = 'Anomaly'; FeatureName = $f.FeatureName; Ticket = $ticket
Reason = "F14: requester '$requesterFromTicket' also performed the change — dual-control violation"
})
continue
}
$changeFindings.Add([pscustomobject]@{
Status = 'Clean'
FeatureName = $f.FeatureName
Ticket = $ticket
Actors = $actors
AuditCount = $auditMatches.Count
})
}
$manifest = New-Feat224EvidenceManifest -RunId $RunId -Findings $changeFindings.ToArray() `
-EvidenceRoot $EvidenceRoot -PreviousManifestPath $PreviousManifestPath
$rollup = if ($changeFindings | Where-Object Status -eq 'Anomaly') { 'Anomaly' } else { 'Clean' }
return [pscustomobject]@{
Status = $rollup
ManifestPath = $manifest.ManifestPath
ManifestHash = $manifest.ManifestHash
ChangeFindings = $changeFindings.ToArray()
Reason = if ($rollup -ne 'Clean') { 'See ChangeFindings — F2 / F14 / hash chain issues.' } else { $null }
}
}
10.2 Pester (CHANGE)
Describe 'Feat224 — CHANGE namespace' -Tag CHANGE {
It 'flags F2 when catalog row has no ChangeTicket' {
Mock Invoke-Feat224PagedQuery { [pscustomobject]@{ Status='Clean'; Items=@() } }
Mock New-Feat224EvidenceManifest { [pscustomobject]@{ ManifestPath='x'; ManifestHash='h' } }
$cat = @{ 'X' = @{ fsi_featurename='X' } }
$r = Export-Feat224ChangeEvidence -AllFindings @([pscustomobject]@{ FeatureName='X' }) -CatalogIndex $cat -EvidenceRoot 'C:\tmp' -RunId 'r1'
($r.ChangeFindings | Where-Object Reason -match 'F2').Count | Should -BeGreaterThan 0
}
It 'flags F14 dual-control violation' {
$cat = @{ 'Y' = @{ fsi_featurename='Y'; fsi_changeticket='CHG-9'; fsi_requesterupn='alice@contoso.com' } }
Mock Invoke-Feat224PagedQuery {
[pscustomobject]@{ Status='Clean'; Items=@(@{
additionalDetails=@(@{ value='CHG-9' });
initiatedBy=@{ user=@{ userPrincipalName='alice@contoso.com' } }
}) }
}
Mock New-Feat224EvidenceManifest { [pscustomobject]@{ ManifestPath='x'; ManifestHash='h' } }
$r = Export-Feat224ChangeEvidence -AllFindings @([pscustomobject]@{ FeatureName='Y' }) -CatalogIndex $cat -EvidenceRoot 'C:\tmp' -RunId 'r2'
($r.ChangeFindings | Where-Object Reason -match 'F14').Count | Should -BeGreaterThan 0
}
}
§11 — Sovereign-cloud compensating runner
Because GCC, GCC High, and DoD tenants do not yet (April 2026) expose the same Copilot admin-center, Copilot Studio beta, and Agent Framework endpoints as commercial, this helper is the documented compensating control. It runs the subset of helpers that do work in sovereign clouds, marks the rest NotApplicable, produces a separate per-cloud catalog snapshot, and emits a parity-diff report so that the AI Governance Lead can track surfaces as they reach GA in sovereign.
11.1 Helper
function Invoke-Feat224SovereignRegister {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $TenantId,
[Parameter(Mandatory)] [ValidateSet('GCC','GCCHigh','DoD')] [string] $Cloud,
[Parameter(Mandatory)] [string] $JustificationTicket,
[Parameter(Mandatory)] [string] $EvidenceRoot,
[hashtable] $CatalogIndex
)
$session = Initialize-Feat224Session -TenantId $TenantId -Cloud $Cloud -JustificationTicket $JustificationTicket -AcceptPreview
if ($session.Status -in 'Anomaly','Error') { return $session }
$parityMatrix = @(
@{ Surface = 'M365Hub'; Endpoint = 'graph/v1.0/copilot/settings'; GccStatus='Partial'; GccHighStatus='Gap'; DoDStatus='Gap' },
@{ Surface = 'M365Hub.Beta'; Endpoint = 'graph/beta/copilot/features'; GccStatus='Gap'; GccHighStatus='Gap'; DoDStatus='Gap' },
@{ Surface = 'PPAC'; Endpoint = 'BAP/scopes/admin/environments'; GccStatus='Available';GccHighStatus='Partial';DoDStatus='Partial'},
@{ Surface = 'CopilotStudio'; Endpoint = 'graph/beta/copilotStudio/agents'; GccStatus='Partial'; GccHighStatus='Gap'; DoDStatus='Gap' },
@{ Surface = 'MCP'; Endpoint = 'agent.mcpServers (beta)'; GccStatus='Gap'; GccHighStatus='Gap'; DoDStatus='Gap' },
@{ Surface = 'AgentFramework'; Endpoint = 'graph/beta/agents/{id}/tools'; GccStatus='Gap'; GccHighStatus='Gap'; DoDStatus='Gap' },
@{ Surface = 'DLP'; Endpoint = 'BAP/dlpPolicies'; GccStatus='Available';GccHighStatus='Available';DoDStatus='Available'},
@{ Surface = 'AuditLog'; Endpoint = 'graph/beta/auditLogs/directoryAudits';GccStatus='Available';GccHighStatus='Available';DoDStatus='Available'}
)
$cloudKey = "$($Cloud)Status"
$findings = New-Object System.Collections.Generic.List[object]
foreach ($row in $parityMatrix) {
$statusInCloud = $row[$cloudKey]
if ($statusInCloud -eq 'Available') {
switch ($row.Surface) {
'PPAC' { $findings.Add((Get-Feat224PpacFeatureState -CatalogIndex $CatalogIndex)) }
'DLP' { $findings.Add((Compare-Feat224DlpAlignment -CatalogIndex $CatalogIndex -McpInventory @())) }
default {
$findings.Add([pscustomobject]@{
Status = 'NotApplicable'; Surface = $row.Surface
Reason = "Surface marked Available but no compensating helper wired in v1.4 — track in next release"
})
}
}
} elseif ($statusInCloud -eq 'Partial') {
$findings.Add([pscustomobject]@{
Status = 'Pending'
Surface = $row.Surface
Reason = "F8 mitigation: $($row.Surface) is PARTIAL in $Cloud — invoke commercial helper but treat null/empty results as Anomaly until GA"
})
} else {
$findings.Add([pscustomobject]@{
Status = 'NotApplicable'
Surface = $row.Surface
Reason = "F8: $($row.Surface) endpoint '$($row.Endpoint)' is not available in $Cloud as of 2026-04-15. Compensating: manual portal evidence (see portal-walkthrough.md §11)."
})
}
}
$sovereignDir = Join-Path $EvidenceRoot "$Cloud\$(Get-Date -Format 'yyyyMMdd-HHmmss')"
if ($PSCmdlet.ShouldProcess($sovereignDir, 'Write sovereign parity report')) {
New-Item -ItemType Directory -Path $sovereignDir -Force | Out-Null
$parityMatrix | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $sovereignDir 'parity-matrix.json') -Encoding UTF8
$findings | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $sovereignDir 'findings.json') -Encoding UTF8
}
$rollup = if ($findings | Where-Object Status -eq 'Anomaly') { 'Anomaly' }
elseif ($findings | Where-Object Status -eq 'Pending') { 'Pending' }
else { 'Clean' }
return [pscustomobject]@{
Status = $rollup
Cloud = $Cloud
ParityMatrix = $parityMatrix
Findings = $findings.ToArray()
EvidenceFolder = $sovereignDir
Reason = if ($rollup -ne 'Clean') { "$Cloud parity gaps require manual evidence — see portal-walkthrough.md §11." } else { $null }
}
}
11.2 Pester (SOV)
Describe 'Feat224 — SOV namespace' -Tag SOV {
It 'returns NotApplicable for surfaces with sovereign gaps' {
Mock Initialize-Feat224Session { [pscustomobject]@{ Status='Clean' } }
Mock Get-Feat224PpacFeatureState { [pscustomobject]@{ Status='Clean'; Findings=@() } }
Mock Compare-Feat224DlpAlignment { [pscustomobject]@{ Status='Clean'; Findings=@() } }
$r = Invoke-Feat224SovereignRegister -TenantId 't' -Cloud 'GCCHigh' -JustificationTicket 'CHG-1' -EvidenceRoot $env:TEMP -CatalogIndex @{} -WhatIf
($r.Findings | Where-Object { $_.Surface -eq 'MCP' -and $_.Status -eq 'NotApplicable' }).Count | Should -BeGreaterThan 0
}
}
Hedged claim. This compensating runner aids in maintaining a defensible inventory in sovereign clouds while Microsoft closes parity gaps; it does not substitute for the commercial-cloud helpers and does not guarantee detection of unauthorized features that live exclusively in sovereign-only surfaces.
§12 — Sentinel / SIEM forwarding
Per Control 2.12 (Supervision) and Control 1.10 (Communication Compliance Monitoring), feature-change events must reach the SIEM. This helper uses the Logs Ingestion API via a Data Collection Endpoint (DCE) and Data Collection Rule (DCR), plus a canary event with a known correlation ID to detect defect F12 (DCR-side filtering silently dropping events).
12.1 Helper
function Send-Feat224SiemEvent {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $DceUri,
[Parameter(Mandatory)] [string] $DcrImmutableId,
[Parameter(Mandatory)] [string] $StreamName,
[Parameter(Mandatory)] [object[]] $Events,
[string] $CanaryCorrelationId = 'FEAT224-CANARY'
)
# Append canary
$canary = [pscustomobject]@{
TimeGenerated = (Get-Date).ToUniversalTime().ToString('o')
ControlId = '2.24'
EventType = 'Canary'
CorrelationId = $CanaryCorrelationId
Surface = 'self-test'
Reason = 'F12 detector — round-trip validation event'
}
$payload = @($Events) + @($canary)
$token = (Get-AzAccessToken -ResourceUrl 'https://monitor.azure.com').Token
$uri = "$DceUri/dataCollectionRules/$DcrImmutableId/streams/$StreamName?api-version=2023-01-01"
if ($PSCmdlet.ShouldProcess($uri, "POST $($payload.Count) events")) {
try {
$resp = Invoke-WebRequest -Method POST -Uri $uri `
-Headers @{ Authorization = "Bearer $token"; 'Content-Type' = 'application/json' } `
-Body ($payload | ConvertTo-Json -Depth 10) -UseBasicParsing
} catch {
return [pscustomobject]@{ Status='Error'; Reason="Logs Ingestion POST failed: $($_.Exception.Message)" }
}
if ($resp.StatusCode -ne 204) {
return [pscustomobject]@{ Status='Anomaly'; Reason="Unexpected status $($resp.StatusCode); expected 204"; HttpStatus=$resp.StatusCode }
}
}
# Canary echo verification (defect F12)
Start-Sleep -Seconds 90
$kustoCheck = [pscustomobject]@{
Note = "Manual or scheduled KQL: $($StreamName) | where CorrelationId == '$CanaryCorrelationId' | summarize Last=max(TimeGenerated)"
Reason = 'Canary verification must be performed against Log Analytics workspace; helper cannot do it without workspace credentials.'
}
return [pscustomobject]@{
Status = 'Pending'
Reason = 'F12 mitigation: 204 acknowledged; canary echo must be confirmed via KQL within 5 minutes'
SubmittedCount = $payload.Count
CanaryCorrelation = $CanaryCorrelationId
VerificationHint = $kustoCheck
}
}
12.2 Pester (SIEM)
Describe 'Feat224 — SIEM namespace' -Tag SIEM {
It 'returns Pending after a successful 204 (canary echo unverified)' {
Mock Get-AzAccessToken { @{ Token = 'fake' } }
Mock Invoke-WebRequest { [pscustomobject]@{ StatusCode = 204 } }
Mock Start-Sleep { }
$r = Send-Feat224SiemEvent -DceUri 'https://x' -DcrImmutableId 'd' -StreamName 's' -Events @(@{ Foo='bar' })
$r.Status | Should -Be 'Pending'
$r.Reason | Should -Match 'F12'
}
It 'returns Anomaly for unexpected status code' {
Mock Get-AzAccessToken { @{ Token = 'fake' } }
Mock Invoke-WebRequest { [pscustomobject]@{ StatusCode = 200 } }
Mock Start-Sleep { }
(Send-Feat224SiemEvent -DceUri 'https://x' -DcrImmutableId 'd' -StreamName 's' -Events @(@{Foo='bar'})).Status | Should -Be 'Anomaly'
}
}
§13 — Scheduling
Two scheduling modes are supported: (a) Az Automation (recommended for enterprise, integrates with Managed Identity), and (b) local Scheduled Jobs (acceptable for lab/sandbox tenants).
13.1 Az Automation runbook registration
function Register-Feat224Schedule {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $ResourceGroup,
[Parameter(Mandatory)] [string] $AutomationAccount,
[Parameter(Mandatory)] [string] $RunbookName,
[ValidateSet('Daily','Hourly','Weekly')] [string] $Frequency = 'Daily',
[int] $IntervalHours = 24
)
if ($PSCmdlet.ShouldProcess("$AutomationAccount/$RunbookName", 'Schedule')) {
$startTime = (Get-Date).AddHours(1)
$sched = New-AzAutomationSchedule -ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name "feat224-$Frequency" `
-StartTime $startTime `
-HourInterval $IntervalHours
Register-AzAutomationScheduledRunbook -ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-RunbookName $RunbookName `
-ScheduleName $sched.Name | Out-Null
}
return [pscustomobject]@{
Status = 'Clean'
RunbookName = $RunbookName
Frequency = $Frequency
}
}
13.2 Cadence guidance
| Helper | Recommended cadence | Why |
|---|---|---|
Get-Feat224M365FeatureState |
Daily 02:00 UTC | Tenant-wide toggles change rarely; daily aligns with Microsoft service-update cadence |
Get-Feat224PpacFeatureState |
Daily 02:30 UTC | Environment-level changes track admin actions |
Get-Feat224AgentFeatureState |
Every 4 hours | Agent overrides change with maker activity |
Get-Feat224McpAgfState |
Every 4 hours | MCP servers can be added without admin involvement |
Compare-Feat224DlpAlignment |
Daily 03:00 UTC | DLP changes are change-managed |
Compare-Feat224ZoneCompliance |
After each upstream run | Pure rollup |
Export-Feat224ChangeEvidence |
Weekly + ad-hoc on Anomaly | Heavy audit pull |
Invoke-Feat224SovereignRegister |
Daily 04:00 UTC | Sovereign tenants only |
Send-Feat224SiemEvent |
After each rollup | Real-time supervision feed |
Hedged claim. Cadence is a recommendation that helps meet FINRA 3110 supervisory expectations and SR 11-7 ongoing-monitoring expectations. Organizations should validate that their ticketing and CAB workflow can absorb the volume of daily evidence and adjust accordingly.
§14 — Retention alignment with Control 2.13
Evidence manifests are governance records and inherit the retention scheme defined in Control 2.13. The default is 7 years for SOX-aligned manifests and 6 years for FINRA 4511 supervisory evidence; whichever is longer wins.
function Set-Feat224EvidenceRetention {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $EvidenceRoot,
[Parameter(Mandatory)] [string] $StorageAccount,
[Parameter(Mandatory)] [string] $Container,
[int] $RetentionYears = 7,
[switch] $ImmutableLegalHold
)
if ($PSCmdlet.ShouldProcess("$StorageAccount/$Container", "Apply $RetentionYears-year retention")) {
$ctx = (Get-AzStorageAccount -ResourceGroupName $env:FEAT224_RG -Name $StorageAccount).Context
if ($ImmutableLegalHold) {
Add-AzRmStorageContainerLegalHold -ResourceGroupName $env:FEAT224_RG -StorageAccountName $StorageAccount -ContainerName $Container -Tag @('feat224-control-224')
} else {
Set-AzRmStorageContainerImmutabilityPolicy -ResourceGroupName $env:FEAT224_RG -StorageAccountName $StorageAccount -ContainerName $Container -ImmutabilityPeriod ($RetentionYears * 365)
}
}
return [pscustomobject]@{
Status = 'Clean'
StorageAccount= $StorageAccount
Container = $Container
RetentionYears= $RetentionYears
LegalHold = [bool]$ImmutableLegalHold
Reason = $null
}
}
Hedged claim. WORM immutability supports compliance with SEC 17a-4(f)(2)(ii)(A) and FINRA 4511 retention expectations. It does not by itself satisfy the Designated Third-Party (D3P) attestation requirement under SEC 17a-4(f)(3)(vii) — that must be arranged separately with the storage vendor.
§15 — Cross-control references
This control's PowerShell evidence depends on, or is consumed by, the following sister controls. The links below are the canonical anchors used by the §10 evidence exporter when generating cross-references inside manifest payloads.
| Control | Title | Why it matters here |
|---|---|---|
| 1.1 | Restrict Agent Publishing by Authorization | A "Clean" feature catalog row does not imply publishing authorization; defect-catalog F1–F15 explicitly avoid that conflation |
| 1.2 | Agent Registry and Integrated Apps Management | The §6 agent reader joins on AgentId from this registry; broken joins become Pending |
| 1.4 | Advanced Connector Policies (ACP) | §7 DLP correlation reads policies governed under 1.4 |
| 1.10 | Communication Compliance Monitoring | §12 SIEM forwarding lands events that supplement 1.10's CC policies |
| 1.25 | MIME Type Restrictions | A feature toggle that re-enables a previously restricted MIME path is captured by §6 + §8 |
| 2.2 | Environment Groups and Tier Classification | Defect F6 (Default-environment misuse) and F3 (Zone elevation) anchor here |
| 2.6 | Model Risk Management Alignment (OCC 2011-12 / SR 11-7) | Catalog rows with RiskRating in {High, Critical} require an MRM Review ID |
| 2.12 | Supervision and Oversight (FINRA Rule 3110) | Defect F10 (silent agent override) is a direct supervision-program risk |
| 2.17 | Multi-Agent Orchestration Limits | AGF workflow tools enumerated in §9 surface multi-agent paths |
| 2.25 | Agent 365 Admin Center Governance Console | The §4 M365 hub reader and 2.25's console must agree; disagreement is treated as Anomaly |
Final hedged statement. The helpers documented in §§3–14 aid in producing a defensible, auditable record of which Microsoft 365 / Power Platform / Copilot Studio / Agent Framework / MCP features are enabled in which environments, on which agents, under which catalog approval, with which change ticket. They do not, individually or collectively, constitute legal compliance certification. A "Clean" rollup means the configuration matches the approved catalog as of the run timestamp. Implementation requires the role assignments listed in §1.3 and the sovereign-cloud caveats in §11. Organizations should verify each helper's behaviour in a non-production tenant, retain at least one prior manifest for hash-chain validation, and route any
AnomalyorPendingfinding to the AI Governance Lead for adjudication before the next supervisory cycle.
Updated: April 2026 | Version: v1.4.0 | UI Verification Status: Current