feat(server): admins set managed policy from a Group Policy template#27340

closed
t3/managed-policy
GitHub fork: t3/managed-policy-group-policy
opened5d agobyMmattferoz
View on GitHubTwo-way sync enabledPR last imported 2d ago

Stacked on #27197.

Windows administrators currently have to paste the whole managed policy into one registry string. This adds Group Policy controls and a command to generate Windows templates and macOS configuration profiles.

t3 policy templates <dir> [--policy file] writes windows/t3code.admx, windows/en-US/t3code.adml, and macos/t3code.mobileconfig. The optional policy file populates the macOS profile. Windows admins configure the policies in Group Policy Editor. The guide explains both routes.

The Windows reader builds the policy from the values each control writes under HKLM\SOFTWARE\Policies\T3Tools\T3Code. Active settings or limits require Organization, with an error naming that policy. Disabled client, provider, and update groups remove their restrictions; a registry containing only disabled groups counts as unmanaged. Unsupported values, invalid types, broken JSON, and the old single JSON Policy value fail closed.

The reader uses the system Windows PowerShell executable and .NET registry API to read the 64-bit registry view, then sends UTF-8 JSON directly through stdout. There is no writable export file between the registry read and parsing. Profiles and module autoloading are disabled, and the JSON module loads from its absolute system path. Windows PowerShell must be allowed to run. A blocked executable, failed read, or malformed output prevents startup rather than ignoring the policy.

Provider checkboxes come from the registered drivers. A drift test compares the generated template's values with the reader. Multi-control policies explain what Disabled means. macOS profile generation rejects null settings with an error directing the admin to file deployment.

Verification:

  • vp run check:changed HEAD passed format, lint, knip, typechecking of 31 affected packages, and the four tests in the two selected files. The focused reader and template tests also passed.
  • A regression test first reproduced the temp-file bypass on the old reader. The fixed reader retains the restrictions when a forged empty export is present. Tests also reject failed subprocesses and malformed output.
  • In the retained Windows 11 Enterprise VM, an unelevated process could not write the policy key but successfully ran the actual policy CLI with a forged export in TEMP and a fake PowerShell module on the module search path. The policy retained Unicode, quotes, newlines, and trailing spaces. The reader created no export directory.
  • Windows runtime cases cover a single value, disabled-only controls, empty and missing keys, missing Organization, invalid DWORDs, unsupported registry types, and unknown subkeys. Cleanup returns the CLI to unmanaged state.
  • Earlier VM verification loaded all 11 policies in Group Policy Editor, saved the controls, refreshed Group Policy, and read the resulting values with the actual policy CLI. Enabled, Disabled, and Not configured transitions were checked. The generated macOS profile passed plutil -lint.

The VM checks cover Group Policy Editor, the registry, and the policy CLI. Intune/domain deployment and full T3 app enforcement in the VM remain untested. This removes the temp-file bypass; it does not make a process running as the user a tamper-proof security boundary.

Built with Claude Opus 5.5 in Claude Code. Windows verification and the subsequent reader fixes used GPT-6 Astra in the Codex harness, inside T3 Code.

Checks

23 successful · 15 skipped · 1 neutral
All checks passed
1c240ed
apps/server/src/policy/policyTemplates.tsline 318Outdated · c0a437bOpen thread
……@@ -0,0 +1,355 @@
315+ if (Array.isArray(value)) {
316+ return `${indent}<array>\n${value.map((item) => plistValue(item, inner)).join("\n")}\n${indent}</array>`;
317+ }
318+ const entries = Object.entries(value as Record<string, unknown>);
MmacroscopeappBoton line 3185d ago

🟠 High policy/policyTemplates.ts:318

t3 policy templates --policy ... throws and writes no templates when a valid policy contains a nullable setting such as worktreeCleanup: { value: null, locked: true }. plistValue falls through to Object.entries(value) for null; handle null using a representation the macOS policy reader can round-trip.

No longer relevant as of 0271d7cd9c31b9a6577ba4c0570fe97f8e8eea9e

Fixed in 0271d7cd9c: a null value is refused with an error naming the setting instead of crashing. A plist has no null, so there is no representation to round-trip.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Synced from GitHub

Approvability

Verdict: Needs Human Approval

Macroscope's review found this PR requires human approval — Adds administrator policy templates and a new Windows registry reader. Windows startup now requires PowerShell and rejects the previous single-value policy format, so rollout compatibility and migration need confirmation.

You can add or adjust custom eligibility rules. Learn more.

JJulius Marmingereviewed · 4 inline threads5d ago

Nice work. Dropping the single JSON Policy value instead of supporting both formats is the right call, and the fail-closed cases plus the template/reader drift test cover what matters. All checks pass.

Four things before this merges into t3/managed-policy. Details are inline:

  1. t3 policy templates --policy crashes on a null setting value.
  2. A Group Policy setup without the Organization policy breaks every machine with an error a Windows admin can't map back to a policy.
  3. The help text doesn't say what Disabled means for the multi-control policies.
  4. Please check non-ASCII text on your Windows PC. reg.exe output may not be UTF-8 when piped. #27197 has the same exposure, so this isn't a regression from this PR.

Could be follow-ups:

  • Admins expect to download the .admx/.adml from the release page. Generating them in the release build and attaching them would spare them installing the t3 CLI.
  • parseRegistryValues calls trimEnd() on each line, which drops trailing spaces from string values. That's harmless for now.

The remaining real-world check is still loading the template in Intune, or in the Group Policy editor on a Pro machine.

apps/server/src/policy/policyTemplates.tsline 318Outdated · c0a437bOpen thread
……@@ -0,0 +1,355 @@
315+ if (Array.isArray(value)) {
316+ return `${indent}<array>\n${value.map((item) => plistValue(item, inner)).join("\n")}\n${indent}</array>`;
317+ }
318+ const entries = Object.entries(value as Record<string, unknown>);
JJulius Marmingeon line 3185d ago

A policy can lock a setting that can be empty, e.g. {"defaultModelSelection": {"value": null, "locked": true}}, since setting values are Schema.Unknown. null reaches this line and Object.entries(null) throws a TypeError, so the command crashes. A plist has no null, so refuse with a clear error naming the setting, e.g. "settings.defaultModelSelection is null, which a configuration profile cannot hold; deploy this policy as a file."

Fixed in 0271d7cd9c. macosProfile now refuses a policy with a null anywhere in it, and the CLI prints "settings.defaultModelSelection is null, which a configuration profile cannot hold; deploy this policy as a file." It writes no files in that case. There is a test for it.

packages/shared/src/managedPolicyFile.tsline 214Outdated · c0a437bOpen thread
……@@ -103,13 +129,91 @@ const run = Effect.fnUntraced(function* (command: string, args: ReadonlyArray<st
211+ for (const [key, list] of lists) {
212+ setPath(document, WINDOWS_POLICY_LISTS[key as keyof typeof WINDOWS_POLICY_LISTS], list);
213+ }
214+ return { document };
JJulius Marmingeon line 2145d ago

If an admin enables, say, "Broadest permission mode" without the Organization policy, every machine refuses to start. That is fail-closed, as intended, but the error is a schema miss at ["organization"], which a Windows admin will not connect to a Group Policy setting. Suggest returning { error: "set the Organization policy whenever another T3 Code policy is set" } here when document.organization is missing, with a row in the fail-closed table.

Fixed in 0271d7cd9c. The reader now returns "set the Organization policy whenever another T3 Code policy is set", with a row in the fail-closed table. Checked on a Windows 10 PC: with Organization removed, t3 policy show refuses with that message.

apps/server/src/policy/policyTemplates.tsline 130Outdated · c0a437bOpen thread
……@@ -0,0 +1,355 @@
127+ {
128+ id: "AllowedDrivers",
129+ title: "Allowed providers",
130+ help: "Only the checked providers may run. Threads on other providers stay, marked unavailable.",
JJulius Marmingeon line 1305d ago

For policies with elements, Disabled makes Group Policy delete their values, so Disabled means no restriction. "Allowed providers: Disabled" reads like "no providers allowed". One line in this help text and in the Clients (78) and Updates (142) ones would fix it: "Disabled or Not Configured: no restriction."

Added "Disabled or Not Configured: no restriction." to Allowed clients, Allowed providers and Updates, and to Broadest permission mode, which has the same problem.

packages/shared/src/managedPolicyFile.tsline 286Outdated · c0a437bOpen thread
……@@ -179,16 +283,19 @@ const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platfor
180284 return yield* new ManagedPolicyFileError({ path, reason: "the registry cannot be read" });
181285 }
182- const queried = yield* query([path, "/v", "Policy"]);
286+ const queried = yield* query([path, "/s"]);
JJulius Marmingeon line 2865d ago

Worth a two-minute check on your Windows PC: when reg.exe output is piped it is likely written in the console code page rather than UTF-8, and Stream.decodeText() decodes it as UTF-8. Set Organization to "Société Générale" and run t3 policy show. If it comes back garbled, a non-ASCII environment value in ProviderInstances would also arrive changed without any error. #27197 has the same exposure through its single value.

Checked on the PC: it is garbled. The machine runs code page 437, piped reg query wrote é as 0x82, and Société decoded as Soci�t�. Characters outside that code page were dropped entirely. The reader now uses reg export, which writes UTF-16 to a temp file that is read and deleted. With Société Générale 日本 ✓ as Organization and an accented gateway URL in a ProviderInstances environment variable, t3 policy show on the PC printed both unchanged. cmd /c chcp 65001 also worked, but I left it out because "Prevent access to the command prompt" can block cmd.exe and T3 Code would then refuse to start. Since this PR removes the single JSON value, it covers the #27197 exposure too.

packages/shared/src/managedPolicyFile.tsline 322Outdated · 0271d7cOpen thread
……@@ -179,16 +307,30 @@ const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platfor
319+ new ManagedPolicyFileError({ path, reason: "the registry cannot be read", cause }),
320+ ),
321+ );
322+ if (exported === 1) return undefined;
MmacroscopeappBoton line 3225d ago

🟠 High src/managedPolicyFile.ts:322

A failed reg export returns undefined here, so readManagedPolicy treats the machine as unmanaged and skips administrator restrictions even when the policy exists. Exit code 1 also means export failure, not only a missing key; distinguish absence from export failure and propagate the latter as ManagedPolicyFileError.

No longer relevant as of 6329fd94c4840e000c8e83ebf482a5b2b32b6a91

Fixed in 6329fd94c4. The reader now runs reg query on the policy key first: exit 1 there means no policy, since the Policies root query already showed the registry is readable. Once the key exists, any reg export failure refuses to start with "the registry cannot be read". The new test fails on the previous commit.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

packages/shared/src/managedPolicyFile.tslines 221–228Outdated · 501567aOpen thread
……@@ -103,13 +134,132 @@ const run = Effect.fnUntraced(function* (command: string, args: ReadonlyArray<st
225+ if (checked === undefined) return { error: `${where} must be a DWORD of 0 or 1` };
226+ const list = lists.get(value.key) ?? [];
227+ lists.set(value.key, list);
228+ if (checked) list.push(value.name);
MmacroscopeappBoton lines 221–2285d ago

🟡 Medium src/managedPolicyFile.ts:221

A valid export with the subkey clients and an enabled desktop value is rejected as an unknown policy key, causing startup to fail instead of applying the allowed-clients policy. Object.hasOwn compares value.key case-sensitively, unlike Windows registry key names; normalize the subkey to its canonical policy-key name before checking and storing it.

No longer relevant as of fe8b263a90e54ca2795fa61faec1ef9f772b3681

All four points are addressed:

  • Null settings now produce a clear error naming the setting and recommending a policy file, with regression coverage.
  • Missing Organization now explicitly says to set the Organization policy.
  • The multi-control help text explains that Disabled or Not configured means no restriction.
  • The reader uses UTF-16 reg export. Société Générale survived the full editor → registry → t3 policy show path unchanged. The old trimEnd() parser is gone too.

The real Group Policy check is now done in a Windows 11 Enterprise evaluation VM. All 11 controls loaded without errors. Applying policies, disabling restrictions, and returning everything to Not configured were verified through the editor and the actual policy CLI. That caught a checkbox Disabled-state bug, fixed in 501567a426.

I also addressed the latest registry-subkey casing finding in fe8b263a90. In the VM, the previous reader rejected lowercase clients/alloweddrivers; the fixed reader applied the same policy successfully. Focused checks and CI pass for that commit.

Release-page template downloads and Intune/domain deployment remain follow-ups. The VM has been rebuilt and retained with a “Ready for policy testing” snapshot for further testing.

JJulius Marmingereviewed · 1 inline thread5d ago

Thanks, that covers everything. Switching to reg export (UTF-16) is a better fix for the encoding than anything I suggested, and catching that Windows 11 leaves each checkbox's falseValue behind on Disabled was a good find. CI is green on fe8b263a.

One edge case is left, inline: a lone Disabled policy now refuses to start. After that I'm happy to merge this into t3/managed-policy.

packages/shared/src/managedPolicyFile.tsline 260Outdated · fe8b263Open thread
……@@ -103,13 +134,135 @@ const run = Effect.fnUntraced(function* (command: string, args: ReadonlyArray<st
257+ put(WINDOWS_POLICY_LISTS[key as keyof typeof WINDOWS_POLICY_LISTS], list);
258+ }
259+ // The schema would name `["organization"]`, which an admin cannot map to a Group Policy setting.
260+ if (document.organization === undefined) {
JJulius Marmingeon line 2605d ago

If the only configured T3 Code policy is a multi-control one set to Disabled, for example Allowed providers, the key holds just AllowedDrivers=0 and its unchecked boxes. The built document is { schemaVersion: 1 }, so this returns "set the Organization policy…" and every machine refuses to start. Disabled is documented as "no restriction", and setting a policy to Disabled is a common way to undo it. I checked this against your head with the export AllowedDrivers=dword:0 plus AllowedDrivers\\codex=0 and AllowedDrivers\\claudeAgent=0. Suggest: when the document has nothing besides schemaVersion, return no policy, the same as an empty key. Require Organization only when settings or limits remain. One row in the existing table would cover it.

Fixed in 16ac6a47a0. After validating the registry values, the reader now treats a document containing only schemaVersion as no native policy. Active settings or limits still require Organization; unknown values still fail.

Extended the existing test for disabled provider/client/update groups, including coexistence with a JSON policy file and the fail-closed cases. The regression failed before the fix. vp run check:changed HEAD^ passes.

Verified in the retained Windows Enterprise VM: set only Allowed providers to Disabled in Group Policy Editor, with Organization Not configured, then ran gpupdate. The old CLI exited 1 with the Organization error; the fixed CLI exited 0 and reported no managed policy. Returned the control to Not configured afterward.

packages/shared/src/managedPolicyFile.tsline 347Outdated · 16ac6a4Open thread
……@@ -174,21 +329,38 @@ const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platfor
344+ const fs = yield* FileSystem.FileSystem;
345+ const file = `${yield* fs.makeTempDirectoryScoped({ prefix: "t3code-policy-" })}\\policy.reg`;
346+ const { exitCode } = yield* run(REG, ["export", path, file, "/y", "/reg:64"]);
347+ return exitCode === 0 ? yield* fs.readFileString(file, "utf-16le") : exitCode;
MmacroscopeappBoton line 3475d ago

🔴 Critical src/managedPolicyFile.ts:347

A non-admin can make T3 treat the protected HKLM policy as absent or remove its restrictions by replacing policy.reg before it is read. makeTempDirectoryScoped uses os.tmpdir(), which can point to a user-controlled TEMP/TMP directory, so the export file is not a trustworthy transport; read the policy through a channel the user cannot modify, such as a registry API or pipe.

No longer relevant as of 786cc08a851fe781623c5b6c362c9173211a3a85

Fixed in 786cc08a851fe781623c5b6c362c9173211a3a85. The reader no longer exports or reads a policy file in TEMP. It reads HKLM through the .NET registry API in system Windows PowerShell and sends UTF-8 JSON directly through stdout. Profiles and module autoloading are disabled; the JSON module loads by its absolute system path. Nonzero exits and malformed output fail closed.

The regression test failed on the old reader with a forged empty export and now retains the restrictions. vp run check:changed HEAD passed before the commit.

I also ran the actual policy CLI in the retained Windows 11 Enterprise VM from an unelevated process. It could not write HKLM, but it read the policy correctly with a forged export in TEMP and a fake PowerShell module on the search path. Unicode, quotes, newlines, and trailing spaces survived. The missing/empty/disabled-only cases and the invalid-value cases passed, and cleanup returned the VM to unmanaged state.

This fixes the reported writable-file transport. It does require system Windows PowerShell to be allowed; if it cannot run, policy reading fails closed. It does not claim to prevent every bypass available to a user who controls the T3 Code process.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.