GPO / Scheduled Task / Extension Configurator

Introduction

When a client checks for Group Policy updates, it follows a structured process to determine what needs to be applied.

Version : Before doing anything, the client compares its locally cached GPO version against the remote version, stored both as a versionNumber attribute on the GPO object and in the GPT.ini file on SYSVOL. If the remote version isn’t newer, processing stops there (unless gpupdate /force is used).

CSE GUIDs extensions : If an update is detected, the client inspects the gPCMachineExtensionNames and gPCUserExtensionNames attributes to know which Client-Side Extensions (CSEs) to load. For Immediate Scheduled Tasks specifically, two GUID blocks must be present (one referencing the Core GPO Engine and Scheduled Tasks CSEs and another pairing the Preference CSE with the Scheduled Tasks tool CSE. Without these GUIDs, the client won’t even look at scheduled task configuration.

ScheduledTasks.xml : Once the correct CSEs are identified, the client fetches ScheduledTasks.xml from a predictable SYSVOL path, parses it, and registers the tasks locally.

In short, three things must align for a GPO-based scheduled task to apply correctly: the version number must be incremented, the right CSE GUIDs must be declared on the GPO object, and the ScheduledTasks.xml file must exist in the expected SYSVOL location.

Script

# ============================================================
# GPO Scheduled Task Extension Configurator
# - Backs up GPO before any modification
# - Adds required CSE GUIDs for Immediate Scheduled Tasks
# ============================================================

Import-Module GroupPolicy
Import-Module ActiveDirectory

# ---------- CONFIGURATION ----------
$GPOName    = "Ma GPO"
$DomainDN   = (Get-ADDomain).DistinguishedName
$DomainFQDN = (Get-ADDomain).DNSRoot
$BackupPath = Join-Path -Path (Get-Location) -ChildPath "GPO_Backups"
# -----------------------------------

# Required CSE GUIDs for Immediate Scheduled Tasks (Machine scope)
$RequiredExtensionString = "[{00000000-0000-0000-0000-000000000000}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}][{AADCED64-746C-4633-A97C-D61349046527}{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}]"

# GUID friendly names for display
$GUIDNames = @{
    "{00000000-0000-0000-0000-000000000000}" = "Core GPO Engine"
    "{79F92669-4224-476C-9C5C-6EFB4D87DF4A}" = "Preference Tool CSE - Local Users and Groups"
    "{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}" = "Preference Tool CSE - Scheduled Tasks"
    "{AADCED64-746C-4633-A97C-D61349046527}" = "Preference CSE - Scheduled Tasks"
}

# ============================================================
# HELPER — Pretty print extension blocks
# ============================================================
function Format-ExtensionBlocks {
    param ([string]$ExtensionString)

    if ([string]::IsNullOrWhiteSpace($ExtensionString)) {
        Write-Host "    (empty)" -ForegroundColor DarkGray
        return
    }

    $blocks = [regex]::Matches($ExtensionString, '\[\{[A-F0-9\-\}\{]+\}\]')
    $blockIndex = 1
    foreach ($block in $blocks) {
        Write-Host "    Block $blockIndex : $($block.Value)" -ForegroundColor White
        $guids = [regex]::Matches($block.Value, '\{[A-F0-9\-]+\}')
        foreach ($guid in $guids) {
            $name = $GUIDNames[$guid.Value.ToUpper()]
            if ($name) {
                Write-Host "             $($guid.Value)  →  $name" -ForegroundColor DarkCyan
            } else {
                Write-Host "             $($guid.Value)" -ForegroundColor DarkGray
            }
        }
        $blockIndex++
    }
}

# ============================================================
# HELPER — Merge extension blocks
# ============================================================
function Merge-ExtensionBlocks {
    param (
        [string]$Current,
        [string]$Required
    )

    $CurrentBlocks = [regex]::Matches($Current, '\[\{[A-F0-9\-\}\{]+\}\]') | ForEach-Object { $_.Value.ToUpper() }
    $RequiredBlocks = [regex]::Matches($Required, '\[\{[A-F0-9\-\}\{]+\}\]') | ForEach-Object { $_.Value.ToUpper() }

    $MergedBlocks = [System.Collections.Generic.List[string]]($CurrentBlocks)

    foreach ($block in $RequiredBlocks) {
        if ($MergedBlocks -notcontains $block) {
            $MergedBlocks.Add($block)
        }
    }

    $Sorted = $MergedBlocks | Sort-Object
    return $Sorted -join ""
}

# ============================================================
# STEP 1 — Retrieve GPO
# ============================================================
Write-Host "`n[*] Retrieving GPO: '$GPOName'..." -ForegroundColor Cyan

try {
    $GPO = Get-GPO -Name $GPOName -ErrorAction Stop
    Write-Host "[+] GPO found: $($GPO.DisplayName) | ID: $($GPO.Id)" -ForegroundColor Green
} catch {
    Write-Host "[-] GPO '$GPOName' not found. Exiting." -ForegroundColor Red
    exit 1
}

# ============================================================
# STEP 2 — Backup GPO
# ============================================================
Write-Host "`n[*] Creating backup in: $BackupPath" -ForegroundColor Cyan

if (-not (Test-Path $BackupPath)) {
    New-Item -ItemType Directory -Path $BackupPath | Out-Null
}

try {
    $Backup = Backup-GPO -Name $GPOName -Path $BackupPath -ErrorAction Stop
    Write-Host "[+] Backup successful." -ForegroundColor Green
    Write-Host "    Backup ID  : $($Backup.Id)"
    Write-Host "    Backup Path: $BackupPath"
    Write-Host "    Timestamp  : $($Backup.BackupTime)"
} catch {
    Write-Host "[-] Backup failed: $_" -ForegroundColor Red
    exit 1
}

# ============================================================
# STEP 3 — Read current gPCMachineExtensionNames
# ============================================================
$GPOPath = "CN={$($GPO.Id)},CN=Policies,CN=System,$DomainDN"

Write-Host "`n[*] Reading current gPCMachineExtensionNames..." -ForegroundColor Cyan

try {
    $ADObject = Get-ADObject -Identity $GPOPath -Properties gPCMachineExtensionNames -ErrorAction Stop
    $CurrentExtensions = $ADObject.gPCMachineExtensionNames
} catch {
    Write-Host "[-] Failed to read AD object: $_" -ForegroundColor Red
    exit 1
}

# ============================================================
# STEP 4 — Compute new extension value
# ============================================================
$NewExtensions = Merge-ExtensionBlocks -Current $CurrentExtensions -Required $RequiredExtensionString

# ============================================================
# STEP 5 — Display change summary and ask for confirmation
# ============================================================
Write-Host "`n============================================================" -ForegroundColor DarkYellow
Write-Host "                   CHANGE SUMMARY" -ForegroundColor DarkYellow
Write-Host "============================================================" -ForegroundColor DarkYellow

Write-Host "`n  GPO Name : $($GPO.DisplayName)"
Write-Host "  GPO ID   : $($GPO.Id)"
Write-Host "  Domain   : $DomainFQDN"

Write-Host "`n--- CURRENT state (gPCMachineExtensionNames) ---" -ForegroundColor Yellow
Format-ExtensionBlocks -ExtensionString $CurrentExtensions

Write-Host "`n--- NEW state after change (gPCMachineExtensionNames) ---" -ForegroundColor Green
Format-ExtensionBlocks -ExtensionString $NewExtensions

# Highlight what will be added
$CurrentBlocks = [regex]::Matches($CurrentExtensions.ToUpper(), '\[\{[A-F0-9\-\}\{]+\}\]') | ForEach-Object { $_.Value }
$NewBlocks     = [regex]::Matches($NewExtensions.ToUpper(), '\[\{[A-F0-9\-\}\{]+\}\]') | ForEach-Object { $_.Value }
$AddedBlocks   = $NewBlocks | Where-Object { $CurrentBlocks -notcontains $_ }

if ($AddedBlocks.Count -eq 0) {
    Write-Host "`n[=] No changes needed — all required extensions already present." -ForegroundColor Green
    exit 0
}

Write-Host "`n--- BLOCKS TO BE ADDED ---" -ForegroundColor Magenta
foreach ($block in $AddedBlocks) {
    Write-Host "  + $block" -ForegroundColor Magenta
}

Write-Host "`n============================================================" -ForegroundColor DarkYellow
Write-Host "[!] A backup has already been saved to: $BackupPath" -ForegroundColor Cyan
Write-Host "============================================================`n" -ForegroundColor DarkYellow

# ---- Confirmation prompt ----
$confirmation = Read-Host "Apply these changes to AD? (yes/no)"

if ($confirmation -notin @("yes", "y")) {
    Write-Host "`n[!] Operation cancelled by user. No changes were made." -ForegroundColor Yellow
    Write-Host "[!] Backup is still available at: $BackupPath`n" -ForegroundColor Yellow
    exit 0
}

# ============================================================
# STEP 6 — Apply changes to AD
# ============================================================
Write-Host "`n[*] Applying changes to AD object..." -ForegroundColor Cyan

try {
    Set-ADObject -Identity $GPOPath -Replace @{ gPCMachineExtensionNames = $NewExtensions } -ErrorAction Stop
    Write-Host "[+] gPCMachineExtensionNames updated successfully." -ForegroundColor Green
} catch {
    Write-Host "[-] Failed to update AD object: $_" -ForegroundColor Red
    Write-Host "[!] Restore the GPO using backup ID: $($Backup.Id) in $BackupPath" -ForegroundColor Yellow
    exit 1
}

# ============================================================
# STEP 7 — Verify
# ============================================================
Write-Host "`n[*] Verifying applied value..." -ForegroundColor Cyan

$Verify = Get-ADObject -Identity $GPOPath -Properties gPCMachineExtensionNames
Write-Host "`n--- APPLIED state (gPCMachineExtensionNames) ---" -ForegroundColor Green
Format-ExtensionBlocks -ExtensionString $Verify.gPCMachineExtensionNames

Write-Host "`n[✓] Done. Scheduled Task CSE extensions applied to '$GPOName'." -ForegroundColor Green
Write-Host "[✓] Recovery backup available at: $BackupPath`n" -ForegroundColor Green