ENGINEERING

Group Policy Preferences is the thing nobody replaced

There’s a thread on r/Intune I keep thinking about. An admin is moving 2,000 devices off Group Policy, and the thing blocking them is not security baselines or app packaging - it’s this list:

That is Group Policy Preferences. Five things, none of them special, all of them required. Their GPO does it today, Intune does not, and there’s no migration path that doesn’t end in “write a PowerShell script.”

I’ve been that person, so we built the first four into RFF. This is what that actually takes, and the bug I found doing it that would have shipped straight past if the code didn’t start telling on itself.


What GPP actually did

GPP shipped in Server 2008 and it quietly solved a hard problem. Three properties made it work, and every replacement I’ve seen drops at least two of them.

It ran in the user’s session. The Group Policy Client launched preference processing as the signed-in user. A mapped drive is a per-user, per-logon-session object. So is a printer connection. So is a desktop shortcut - “the desktop” is a folder inside somebody’s profile. If you are not that user, you are NOT doing the thing.

It had item-level targeting. S: maps only for members of Finance. The check happened on the box, in the user’s own token, because that’s the only place the answer exists. The server has no idea who’s sitting at that machine right now.

It reconciled. Preferences reapplied at policy refresh. Delete the item from the GPO and the Delete action removed it. Change the target and Replace fixed it. It was desired state, not a one-shot.

Why “just use a PowerShell script” doesn’t work

This is the standard advice, via Platform Scripts or Proactive Remediations. Here’s each way it breaks.

It runs as SYSTEM by default. So you map a drive and it appears in a profile no human will ever open. This is the same failure mode as winget install under SYSTEM putting an app somewhere the user can’t see it. The script “succeeded”. Nothing happened. Exit code 0.

There’s no item-level targeting. You’re back to writing group-membership checks by hand inside the script, and you get to decide what happens when the DC is unreachable.

There’s no clean removal. GPP’s Delete action is the part that people forget they were relying on. When you remove a drive map from a GPO, it goes away on the endpoints. A script that maps a drive has no idea what it mapped last week. The usual fix is a second “cleanup” script, which is now another thing to maintain and another thing to get wrong.

It fires once. Laptops sleep, VPNs reconnect, sessions unlock. A one-shot at logon is not the same as reapplying.

None of these are Intune being lazy. They’re what you get when the answer to “run something in the user’s session, on a schedule, with targeting” is “here is a script runner.”


The backbone: we already run code as the logged-on user

The reason we could build this is that the hard part was already done for other reasons. The RFF agent runs as SYSTEM, and it can launch a process in the active interactive session with CreateProcessAsUser on a borrowed explorer token. That’s the same technique SCCM used, and the same one the GP engine effectively used.

So the shape is:

  1. The server resolves the device’s effective set on the agent’s poll. It knows which device groups the machine is in, so it sends only rows that apply.
  2. The SYSTEM agent writes that desired state to a file in ProgramData and launches RFF.exe --userenv-reconcile <file> as the signed-in user.
  3. That user-context process does the work, and writes back a per-row result.

Nothing about the config comes from an untrusted local source. It rides the authenticated poll, same trust root as every other piece of agent config.

One deliberate non-participant: the RFF Companion, the tray app. It has a hard render-only contract - it receives things to display and executes nothing. A UNC path is precisely “a path to act on”, so routing this through the Companion would have turned a user-facing surface into an execution vector. It stays out.

The rules that make it safe

Most of the work here is not “write the file”. It’s deciding what you’re allowed to touch.

Only ever delete what you created. The reconciler keeps a per-user state file of what it made. A user’s own manually-created desktop shortcut, or a drive they mapped by hand on the same letter, is not ours and never gets removed. This is the single most important rule in the whole feature. Get it wrong once and you’ve deleted something a person cared about, on every machine, simultaneously.

Remove is not recursive - ever. A remove row on a folder deletes it if it’s empty and reports “not deleted, not empty” if it’s not. There is no recursive delete anywhere in this code path, because “a policy row caused us to delete a user’s files” is not a bug I want to be on the other end of. If you genuinely want the contents gone, you empty it yourself first. That’s a feature.

Create means create, replace means replace. GPP semantics, kept exactly: create leaves an existing item alone, replace overwrites it. If you already have a Timesheets shortcut that we didn’t put there, create will not touch it.

Here is the whole cleanup decision, which is about as much code as it should be:

// A shortcut we USED to manage that is no longer desired gets deleted - but only if it
// still points where we left it. A user who repurposed the file keeps it.
foreach (var (file, target) in managed)
{
    if (newManaged.ContainsKey(file)) continue;
    if (!File.Exists(file)) continue;
    var current = ReadShortcutTarget(file);
    if (SameTarget(current, target))
        File.Delete(file);
}

That SameTarget used to be string.Equals(current, target, OrdinalIgnoreCase) - which brings me to the bug.


Windows rewrites your shortcut behind your back

Shortcuts come in two flavors. A .lnk is a binary shell link. A .url is an INI file. Both are written through the same WScript.Shell COM object, so the code looks identical for both.

I wrote a URL shortcut to https://getrff.com, deleted the policy row, and the icon stayed on the desktop. Cleanup had decided the user must have repurposed the file.

Here’s the entire thing, on a real Windows box:

$w  = New-Object -ComObject WScript.Shell
$sc = $w.CreateShortcut("$env:TEMP\probe.url")
$sc.TargetPath = 'https://getrff.com'
$sc.Save()

(New-Object -ComObject WScript.Shell).CreateShortcut("$env:TEMP\probe.url").TargetPath
stored   : https://getrff.com
read back: https://getrff.com/

Windows normalizes the URL on save. It appends the root path. Look at the file it actually wrote:

[InternetShortcut]
URL=https://getrff.com/

So every byte comparison of “what I wrote” against “what’s on disk” is false, for every .url shortcut. Two things broke off that one character:

Cleanup never deleted its own files. Delete a row and the icon stayed on every desktop permanently. Worse, the file was now sitting there unmanaged, so any future row with the same name hit the create rule - “something is already here that we didn’t make, leave it alone” - and was skipped forever. One trailing slash, and that shortcut name was poisoned on that machine for good.

Every .url was rewritten every five minutes. There’s a diff-before-save specifically so the reconciler does not churn file timestamps on its re-assert cadence. It compared the same two values. It never matched. So it saved. Every cycle, on every machine, for the life of the row.

The fix is boring, which is the correct amount of interesting for a fix:

// Windows appends a root path when it SAVES a .url, so "https://host" reads back as
// "https://host/". A trailing separator is meaningless in a URL and in a path, so trim
// both sides before comparing. Harmless for .lnk targets, and it fixes both call sites.
private static readonly char[] Trailers = { '/', '\\' };

private static bool SameTarget(string? a, string? b) =>
    a != null && b != null
    && string.Equals(a.TrimEnd(Trailers), b.TrimEnd(Trailers), StringComparison.OrdinalIgnoreCase);

The reason I found it at all

Here’s the part I actually want to talk about, because the trailing slash is just trivia.

That bug had been live since the feature shipped. It didn’t produce an error. No failed job, no red badge, nothing in a log anyone reads. The reconciler hit its “this exists and we didn’t create it, leave it alone” branch and moved on without recording anything at all.

So the status panel said: zero failures, and no row for that shortcut. Which is pixel-identical to “applied cleanly.”

I only caught it because I had just changed the agent to report skips as a first-class outcome, alongside ok and failed. The next run came back with:

[qa] Opus HW Check  skipped
  A shortcut with this name already exists and RFF did not create it, so Create
  left it untouched. Use the Replace action to overwrite it.

For a file the agent itself had written ninety seconds earlier. That sentence is the entire reason the bug got found, and it’s why “skipped” now carries a reason everywhere in this feature: an invalid name, a user who’s not in the targeted group, a file we don’t own. If the agent decided not to do something, the row shows the reason.

An action that quietly does nothing is indistinguishable from an action that worked. That’s the bug class, and the trailing slash was just the instance of it that happened to bite me.


The one honest limitation

Azure AD-joined, cloud-only machines mapping to on-prem SMB is the wall. No Kerberos ticket without Azure AD Kerberos and line of sight to a DC. Nothing in the RMM layer changes that.

Where this stands

Shipped: drive maps, printer mappings, shortcuts, folders - each scoped to a device group, each with optional AD or Entra security-group filtering evaluated on the endpoint, each reconciled in the signed-in user’s session, each with clean removal.

Not shipped: the Files extension (drop a file at a path - the tnsnames.ora case), and File System permissions. That last one is the one with real blast radius. A bad ACL on a shared path locks a department out of its own data, and it wants a preview and a dry run before it wants a ship date.

That is four of six. The r/Intune thread that started this needed five of them.

If you are staring down the same migration, the drive maps and shortcuts side of RFF is free to 100 endpoints, and I would genuinely like to hear which of the remaining two matters more to you.