ENGINEERING

Group Policy without a domain controller: parsing 4,641 settings out of your own ADMX

A lot of Windows shops have no domain controller. A dental practice with nine machines. A design studio that went all-in on Google Workspace. An MSP client who was “going to do Intune next year” in 2019. They have Windows, they have real settings they need enforced, and the two ways Microsoft offers to enforce them both assume infrastructure these shops don’t have.

gpedit.msc is on every one of those machines. It’s genuinely good. It’s also per-machine, which means enforcing one setting across nine boxes is nine remote sessions and a note in a spreadsheet that will be wrong by March.

What we wanted was the gpedit experience across a fleet, for people with no fleet infrastructure. (This post is the build story; the product side lives at Group Policy without a domain controller.)

The catalog is already on the machine

The thing that makes gpedit useful isn’t the UI, it’s C:\Windows\PolicyDefinitions. On the Windows 11 box I am writing this on:

225 .admx files   (3.9 MB)  - the policy definitions
226 .adml files   (3.1 MB)  - the en-US display names and help text
3,560 policy definitions

Every setting, its registry key and value name, its data type, its valid options, and Microsoft’s own paragraph explaining what it does. Sitting in XML, on every Windows machine, unread.

So the design decision wasn’t “how do we build a settings catalog.” It was “how do we stop building one.” We parse the customer’s files.

In practice that’s one click: pick a machine, and its agent packs up the templates it already has and sends them. Pick a domain controller if there’s one, because its Central Store is the authoritative set and already includes whatever custom ADMX the org added. There’s a manual upload too, for a machine with no agent, but nobody should have to zip a folder to get started.

One deliberate omission: this does not run on a schedule and it isn’t part of inventory. The templates are byte-identical on every machine running the same Windows build, so collecting them from all 200 endpoints would move about 1.4 GB to learn one thing. It’s a job you ask for, on a machine you chose, and re-running it replaces that source rather than piling up duplicates.

There are two reasons that beats shipping a database of our own.

The first is legal. The help text in the ADML files is Microsoft’s copyrighted content. Parsing a customer’s own files into their own tenant is a very different thing from redistributing Microsoft’s text as our product.

The second is that it’s simply more honest. A shop running Windows 11 24H2 and Office 2021 sees the settings for Windows 11 24H2 and Office 2021. Not a generic superset with a version column they have to squint at.

What parsing actually involves

ADMX isn’t complicated, but it’s fiddly in ways that only show up at scale.

Display names are indirections: displayName="$(string.AutoUpdateBehavior)" points into the ADML string table. Miss one and you show the admin a raw identifier instead of a sentence.

Categories chain across files. A policy in WindowsUpdate.admx parents to a category that lives in windows.admx. Parse files one at a time and you get orphans; parse them as one batch and you get the real gpedit tree back:

Windows Components  3,453
System                600
Network               275
Start Menu and Taskbar 144
Control Panel          83
Printers               46
Desktop                32

That isn’t a tree we designed. It’s the one Microsoft ships, reconstructed from the parent chains.

The form controls are described too. A policy’s <elements> block says this one is an enum with five options, that one is a decimal from 5 to 99, this one is a text box, that one is a list. The ADML’s presentation table supplies the labels and defaults. Which means the editing form can be generated rather than written: 2,316 of those 4,641 settings have configurable options, and not one of them needed a hand-built UI.

Full parse of all 225 files: 4,641 settings in 7.5 seconds, no warnings.

The test that matters: does it agree with gpedit

Parsing is the easy half. The half that can quietly ruin someone’s fleet is turning “Enabled, with these options” into the actual registry writes.

The policy we tested against first was “Configure Automatic Updates”, because it’s the classic trap:

<policy name="AutoUpdateCfg" ... valueName="NoAutoUpdate">
  <enabledValue><decimal value="0" /></enabledValue>
  <disabledValue><decimal value="1" /></disabledValue>

Enabling the policy writes NoAutoUpdate = 0. The value is inverted, because the value name is a negative and the policy is a positive. Any implementation that assumes “enabled means write 1” has just turned automatic updates off on the whole fleet while the console cheerfully reports “Enabled”. Nothing errors. The job goes green.

That’s the failure mode worth designing against, and it isn’t rare. It has a name in our codebase now: successful-but-wrong. A canary ring catches a job that fails. It can’t catch a job that succeeds at the wrong thing.

The bug a second vendor found

Once the Windows set was passing, we uploaded Google Chrome’s enterprise ADMX. Partly to prove that any vendor’s file works, partly because a customer with no domain controller still has Chrome.

Chrome parsed clean: 1,466 settings out of one 894 KB file in 2.3 seconds, its own category tree (“Google > Google Chrome”) resolving correctly.

Then we materialized a URL blocklist and got this:

Software\Policies\Google\Chrome\URLBlocklist\malware.example = malware.example

Google documents it as this:

Software\Policies\Google\Chrome\URLBlocklist\1 = malware.example

The rule, from [MS-GPREG], is genuinely subtle. A list element with no valuePrefix attribute names each value after its own text. A list element with a valuePrefix names them prefix1, prefix2, prefix3. And an empty prefix is still a prefix, so valuePrefix="" means the names are 1, 2, 3.

Our code used the equivalent of IsNullOrEmpty, which treats “no attribute” and “empty attribute” as the same thing. They aren’t.

Chrome and Windows don’t error on the wrong names. They ignore them. The policy deploys, the job goes green, the baseline reports compliant, and not one URL is blocked. Successful-but-wrong again, in a completely different disguise.

It affected every one of Chrome’s 154 list elements (URLBlocklist, URLAllowlist, ExtensionInstallAllowlist) and 30 Windows ones, including “Hide specified Control Panel items”.

Here is the part I want to underline: 225 Microsoft files never surfaced this. Not because Windows doesn’t use empty prefixes, but because we hadn’t happened to author a policy that used one. A second, independent vendor’s corpus found it in about ninety seconds. If you’re building anything that consumes a public file format, one big sample isn’t coverage. Two unrelated samples is the cheapest real test you’ll ever run.

The third state nobody implements correctly

gpedit has three states: Enabled, Disabled, Not Configured. The first two are obvious. The third is where tools quietly diverge.

Our first cut treated “Not configured” as “just don’t add it to the baseline”. That’s wrong, and it is wrong in a way that takes months to notice.

When you flip a real GPO setting back to Not Configured, the value disappears from the machine at the next refresh, because its entry leaves registry.pol and the client-side extension cleans up after itself. When you remove a setting from a desired-state baseline that writes raw registry values, the tool simply stops checking it. The value stays behind on every machine that already got it, tattooed, doing whatever it does, with nothing watching it anymore.

So “Not configured” is now a real state that asserts every value the policy could write is absent: its own value, every element value, both fixed lists, a boolean’s true and false lists, an enum item’s extra writes. It never writes a value, so unlike Disabled it can’t half-apply. The end state is “as if this policy had never been set.”

RFF can’t automatically clear a list element, though, because the ADMX never declares what those value names are.

What this buys a shop with no DC

On a domain-joined machine, this layer defers. Domain GPO wins at every refresh, so fighting it just starts a two-hour reimposition loop; RFF reports those settings and declines to touch them.

On a workgroup machine, there’s no other layer. RFF is the entire policy stack. That’s the case nobody serves well today, and it’s most of the small Windows estates I’ve ever been called into.

The admin’s experience is: click once to collect the templates off a machine, search “automatic updates”, read Microsoft’s own explanation, pick “Enabled”, choose “4 - Auto download and schedule the install” from a dropdown that came out of the ADMX, and watch the exact registry writes appear before committing to anything. Then assign it to a group.

No registry paths. No .reg files emailed around. No nine remote sessions.

The settings were always there. They were just locked inside a tool that could only see one computer at a time.