diff --git a/apps/server/src/cli/policy.ts b/apps/server/src/cli/policy.ts index 99275968fd609..b56c5a0ef60cd 100644 --- a/apps/server/src/cli/policy.ts +++ b/apps/server/src/cli/policy.ts @@ -1,13 +1,17 @@ import * as Console from "effect/Console"; +import type { ManagedPolicyDocument } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as References from "effect/References"; -import { Argument, Command, GlobalFlag } from "effect/cli"; +import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; import * as CliError from "effect/cli/CliError"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import { macosProfile, windowsAdml, windowsAdmx } from "../policy/policyTemplates.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -103,7 +107,62 @@ const checkCommand = Command.make("check", { ), ); +const SAMPLE_POLICY: ManagedPolicyDocument = { + schemaVersion: 1, + organization: "Example Corp", + limits: { maxRuntimeMode: "auto-accept-edits", clients: ["desktop"], telemetry: false }, +}; + +const templatesCommand = Command.make("templates", { + directory: Argument.String("directory").pipe( + Argument.withDescription("Directory to write the templates to."), + ), + policy: Flag.String("policy").pipe( + Flag.withDescription("Policy file to put in the macOS profile. Default: a small sample."), + Flag.optional, + ), +}).pipe( + Command.withDescription( + "Write the Windows Group Policy template and a macOS configuration profile.", + ), + Command.withHandler( + Effect.fn("cli.policy.templates")(function* ({ directory, policy }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let document = SAMPLE_POLICY; + if (Option.isSome(policy)) { + if (!(yield* fs.exists(policy.value))) { + return yield* new CliError.UserError({ + cause: policy.value, + userMessage: `No policy file at ${policy.value}.`, + }); + } + const loaded = (yield* ManagedPolicy.load(policy.value)).document; + if (loaded !== undefined) document = loaded; + } + const random = yield* SecureRandom.SecureRandom; + const profile = macosProfile(document, { + profile: yield* random.uuidV4().pipe(Effect.orDie), + payload: yield* random.uuidV4().pipe(Effect.orDie), + }); + if (profile._tag === "Invalid") { + return yield* new CliError.UserError({ cause: document, userMessage: profile.reason }); + } + const files = [ + [path.join(directory, "windows", "t3code.admx"), windowsAdmx()], + [path.join(directory, "windows", "en-US", "t3code.adml"), windowsAdml()], + [path.join(directory, "macos", "t3code.mobileconfig"), profile.profile], + ] as const; + for (const [file, contents] of files) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, contents); + yield* Console.log(file); + } + }), + ), +); + export const policyCommand = Command.make("policy").pipe( Command.withDescription("Inspect the administrator's managed policy."), - Command.withSubcommands([showCommand, checkCommand]), + Command.withSubcommands([showCommand, checkCommand, templatesCommand]), ); diff --git a/apps/server/src/policy/ManagedPolicy.ts b/apps/server/src/policy/ManagedPolicy.ts index 15167dedd38c2..01651051893c0 100644 --- a/apps/server/src/policy/ManagedPolicy.ts +++ b/apps/server/src/policy/ManagedPolicy.ts @@ -185,7 +185,10 @@ const make = Effect.gen(function* () { const policy = yield* Option.match(devPath, { onSome: load, onNone: () => - readManagedPolicy(platform).pipe( + readManagedPolicy( + platform, + ManagedProviders.knownDriverLabels().map(({ driver }) => driver), + ).pipe( Effect.flatMap((found) => fromDocument(found?.source ?? managedPolicyPath(platform), found?.document), ), diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index 5d9ce6aee62d2..ac390097e150d 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -77,6 +77,10 @@ const KNOWN_DRIVERS: ReadonlyMap = new Map([ ), ]); +/** Each driver a policy may name, with the label users see. */ +export const knownDriverLabels = () => + Array.from(KNOWN_DRIVERS, ([driver, { label }]) => ({ driver, label })); + const PINNED_CONFIG_FIELDS = ["binaryPath", "launchArgs"] as const; type PinnedConfigField = (typeof PINNED_CONFIG_FIELDS)[number]; diff --git a/apps/server/src/policy/policyTemplates.test.ts b/apps/server/src/policy/policyTemplates.test.ts new file mode 100644 index 0000000000000..9d97f0f311994 --- /dev/null +++ b/apps/server/src/policy/policyTemplates.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; +import { + WINDOWS_POLICY_GROUPS, + WINDOWS_POLICY_LISTS, + WINDOWS_POLICY_VALUES, +} from "@t3tools/shared/managedPolicyFile"; + +import { macosProfile, windowsAdmx } from "./policyTemplates.ts"; + +it("gives every registry value the Windows reader accepts a Group Policy control", () => { + const admx = windowsAdmx(); + for (const name of Object.keys(WINDOWS_POLICY_VALUES)) { + assert.include(admx, `valueName="${name}"`); + } + for (const key of Object.keys(WINDOWS_POLICY_LISTS)) { + assert.include(admx, `key="SOFTWARE\\Policies\\T3Tools\\T3Code\\${key}"`); + } + for (const name of Object.keys(WINDOWS_POLICY_GROUPS)) { + assert.include(admx, `valueName="${name}"`); + } +}); + +it("refuses a profile for a policy with a null value, which a plist cannot hold", () => { + const profile = macosProfile( + { + schemaVersion: 1, + organization: "Example Corp", + settings: { defaultModelSelection: { value: null, locked: true } }, + }, + { profile: "profile-uuid", payload: "payload-uuid" }, + ); + assert.deepStrictEqual(profile, { + _tag: "Invalid", + reason: + "settings.defaultModelSelection is null, which a configuration profile cannot hold; deploy this policy as a file.", + }); +}); diff --git a/apps/server/src/policy/policyTemplates.ts b/apps/server/src/policy/policyTemplates.ts new file mode 100644 index 0000000000000..55c15fb94f56c --- /dev/null +++ b/apps/server/src/policy/policyTemplates.ts @@ -0,0 +1,386 @@ +import type { ManagedPolicyDocument } from "@t3tools/contracts"; +import { + WINDOWS_POLICY_KEY, + WINDOWS_POLICY_GROUPS, + WINDOWS_POLICY_LISTS, + WINDOWS_POLICY_VALUES, +} from "@t3tools/shared/managedPolicyFile"; + +import { knownDriverLabels } from "./managedProviders.ts"; + +type ValueName = keyof typeof WINDOWS_POLICY_VALUES; +type ListKey = keyof typeof WINDOWS_POLICY_LISTS; + +type Element = + | { + readonly kind: "text"; + readonly name: ValueName; + readonly label: string; + readonly json?: true; + } + | { + readonly kind: "enum"; + readonly name: ValueName; + readonly label: string; + readonly items: ReadonlyArray<{ readonly value: string; readonly label: string }>; + readonly defaultItem: number; + } + | { + readonly kind: "checkbox"; + readonly list?: ListKey; + readonly name: string; + readonly label: string; + readonly defaultChecked: boolean; + }; + +interface Policy { + readonly id: string; + readonly title: string; + readonly help: string; + /** A policy whose Enabled and Disabled states are themselves the value. */ + readonly toggle?: ValueName; + readonly elements?: ReadonlyArray; +} + +const RUNTIME_MODES = [ + { value: "approval-required", label: "Supervised" }, + { value: "auto-accept-edits", label: "Auto-accept edits" }, + { value: "auto", label: "Auto" }, + { value: "full-access", label: "Full access" }, +]; + +const policies = (): ReadonlyArray => [ + { + id: "Organization", + title: "Organization", + help: 'Required whenever any other T3 Code policy is set: T3 Code will not start with policies that do not name an organization.\n\nUsers see the name as "Managed by " beside every control the policy locks. The support URL is optional.', + elements: [ + { kind: "text", name: "Organization", label: "Organization name" }, + { kind: "text", name: "SupportUrl", label: "Support URL (optional)" }, + ], + }, + { + id: "MaxRuntimeMode", + title: "Broadest permission mode", + help: "The broadest permission mode any thread may use, including threads agents and scheduled tasks start.\n\nA thread already above it cannot run another turn until someone lowers its mode. Under this limit, each provider's launch settings (binary path, launch arguments, environment variables) come only from the policy.\n\nDisabled or Not Configured: no restriction.", + elements: [ + { + kind: "enum", + name: "MaxRuntimeMode", + label: "Broadest permission mode", + items: RUNTIME_MODES, + defaultItem: 1, + }, + ], + }, + { + id: "Clients", + title: "Allowed clients", + help: "What may connect to T3 Code on this machine.\n\nWithout Remote, T3 Code only listens on this machine, does not create pairing links, signs out devices that paired earlier, and turns T3 Connect off. Check at least one client.\n\nDisabled or Not Configured: no restriction.", + elements: [ + { + kind: "checkbox", + list: "Clients", + name: "desktop", + label: "Desktop app on this machine", + defaultChecked: true, + }, + { + kind: "checkbox", + list: "Clients", + name: "local-browser", + label: "A browser on this machine", + defaultChecked: true, + }, + { + kind: "checkbox", + list: "Clients", + name: "remote", + label: "Remote: phones, other computers, app.t3.codes", + defaultChecked: false, + }, + ], + }, + { + id: "Connect", + title: "Allow T3 Connect", + help: "Disabled turns T3 Connect off and unlinks this machine. Other remote access still follows Allowed clients.\n\nEnabled or Not Configured leaves T3 Connect available.", + toggle: "Connect", + }, + { + id: "WebhookTasks", + title: "Allow webhook tasks", + help: "Disabled stops scheduled tasks from using webhook triggers. Existing webhook tasks are kept but do not run.", + toggle: "WebhookTasks", + }, + { + id: "BrowserCookieImport", + title: "Allow importing browser cookies", + help: "Disabled stops users from importing cookies from another browser into T3 Code's browser.", + toggle: "BrowserCookieImport", + }, + { + id: "Telemetry", + title: "Allow usage data", + help: "Disabled stops T3 Code from sending product usage data and relay connection traces. Enabled cannot turn them on for a user who turned them off.", + toggle: "Telemetry", + }, + { + id: "AllowedDrivers", + title: "Allowed providers", + help: "Only the checked providers may run. Threads on other providers stay, marked unavailable.\n\nDisabled or Not Configured: no restriction.", + elements: knownDriverLabels().map(({ driver, label }) => ({ + kind: "checkbox" as const, + list: "AllowedDrivers" as const, + name: driver, + label, + defaultChecked: driver === "codex" || driver === "claudeAgent", + })), + }, + { + id: "Updates", + title: "Updates", + help: "Off stops T3 Code from checking for updates, for machines that receive T3 Code through device management.\n\nA feed URL makes the desktop app update from your own mirror of the release feed instead of GitHub; it must be https. Unchecking nightly makes the nightly channel unavailable.\n\nDisabled or Not Configured: no restriction.", + elements: [ + { + kind: "enum", + name: "UpdatesMode", + label: "Updates", + items: [ + { value: "auto", label: "Automatic" }, + { value: "off", label: "Off" }, + ], + defaultItem: 0, + }, + { kind: "text", name: "UpdatesFeedUrl", label: "Feed URL (optional)" }, + { + kind: "checkbox", + name: "AllowNightly", + label: "Allow the nightly channel", + defaultChecked: true, + }, + ], + }, + { + id: "Settings", + title: "Managed settings (JSON)", + help: 'Defaults and locks for server settings, as the "settings" object of a T3 Code policy. Each key is a setting path; each value is {"value": ..., "locked": true|false}.\n\nExample: {"enableAgentBrowserAccess": {"value": false, "locked": true}}\n\nRun "t3 policy check" on a policy file with the same object to validate it first.', + elements: [{ kind: "text", name: "Settings", label: "Settings JSON", json: true }], + }, + { + id: "ProviderInstances", + title: "Provider settings (JSON)", + help: 'Fields set on every instance of a provider, as the "limits.providers.instances" object of a T3 Code policy: environment variables, binaryPath, launchArgs.\n\nExample: {"claudeAgent": {"locked": true, "environment": [{"name": "CLAUDE_CODE_USE_BEDROCK", "value": "1"}]}}\n\nEvery user on the machine can read this value, so do not put credentials in it.', + elements: [ + { kind: "text", name: "ProviderInstances", label: "Provider settings JSON", json: true }, + ], + }, +]; + +const escapeXml = (text: string) => + text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + +// ADMX keys are relative to the hive the policy class picks. +const ADMX_KEY = WINDOWS_POLICY_KEY.replace(/^HKLM\\/, ""); +const elementId = (element: Element) => + element.kind === "checkbox" && element.list !== undefined + ? `${element.list}_${element.name}` + : element.name; + +const admxElement = (element: Element) => { + const id = elementId(element); + switch (element.kind) { + case "text": + return ``; + case "enum": + return [ + ``, + ...element.items.map( + (item) => + ` ${item.value}`, + ), + "", + ].join("\n "); + case "checkbox": + return ``; + } +}; + +/** The language-neutral Group Policy template. */ +export function windowsAdmx(): string { + const body = policies().map((policy) => { + const head = `\n `; + if (policy.toggle !== undefined) { + return ` ${head} valueName="${policy.toggle}"> + ${common} + + + `; + } + const grouped = Object.hasOwn(WINDOWS_POLICY_GROUPS, policy.id); + return ` ${head}${grouped ? ` valueName="${policy.id}"` : ""} presentation="$(presentation.${policy.id})"> + ${common}${grouped ? '\n \n ' : ""} + + ${(policy.elements ?? []).map(admxElement).join("\n ")} + + `; + }); + return ` + + + + + + + + + + + + + + +${body.join("\n")} + + +`; +} + +const presentationElement = (element: Element) => { + const id = elementId(element); + switch (element.kind) { + case "text": + return ``; + case "enum": + return `${escapeXml(element.label)}`; + case "checkbox": + return `${escapeXml(element.label)}`; + } +}; + +/** The en-US strings and dialog layouts for `windowsAdmx`. */ +export function windowsAdml(): string { + const strings: Array = [ + ["T3Code", "T3 Code"], + ["SUPPORTED_T3Code", "T3 Code with managed policy support"], + ]; + const presentations: Array = []; + for (const policy of policies()) { + strings.push([policy.id, policy.title], [`${policy.id}_Help`, policy.help]); + for (const element of policy.elements ?? []) { + if (element.kind === "enum") { + for (const item of element.items) { + strings.push([`${elementId(element)}_${item.value}`, item.label]); + } + } + } + if (policy.elements !== undefined) { + presentations.push( + ` \n ${policy.elements.map(presentationElement).join("\n ")}\n `, + ); + } + } + return ` + + T3 Code + Policies for T3 Code + + +${strings.map(([id, text]) => ` ${escapeXml(text)}`).join("\n")} + + +${presentations.join("\n")} + + + +`; +} + +const plistValue = (value: unknown, indent: string): string => { + const inner = `${indent} `; + if (typeof value === "string") return `${indent}${escapeXml(value)}`; + if (typeof value === "boolean") return `${indent}<${value} />`; + if (typeof value === "number") { + return Number.isInteger(value) + ? `${indent}${value}` + : `${indent}${value}`; + } + if (Array.isArray(value)) { + return `${indent}\n${value.map((item) => plistValue(item, inner)).join("\n")}\n${indent}`; + } + const entries = Object.entries(value as Record); + return `${indent}\n${entries + .map(([key, item]) => `${inner}${escapeXml(key)}\n${plistValue(item, inner)}`) + .join("\n")}\n${indent}`; +}; + +/** Where `value` holds a `null`, as path segments, or undefined when it holds none. */ +const nullPath = ( + value: unknown, + path: ReadonlyArray, +): ReadonlyArray | undefined => { + if (value === null) return path; + if (typeof value !== "object") return undefined; + for (const [key, item] of Object.entries(value)) { + const found = nullPath(item, [...path, key]); + if (found !== undefined) return found; + } + return undefined; +}; + +/** A device configuration profile that installs `document` for `com.t3tools.t3code`. */ +export function macosProfile( + document: ManagedPolicyDocument, + uuids: { readonly profile: string; readonly payload: string }, +): + | { readonly _tag: "Valid"; readonly profile: string } + | { readonly _tag: "Invalid"; readonly reason: string } { + // A plist has no null, and a setting may be locked empty. + const empty = nullPath(document, []); + if (empty !== undefined) { + const setting = + empty[0] === "settings" && empty.at(-1) === "value" ? empty.slice(0, -1) : empty; + return { + _tag: "Invalid", + reason: `${setting.join(".")} is null, which a configuration profile cannot hold; deploy this policy as a file.`, + }; + } + const profile = { + PayloadContent: [ + { + PayloadDisplayName: "T3 Code policy", + PayloadIdentifier: `com.t3tools.t3code.policy.${uuids.payload}`, + PayloadType: "com.t3tools.t3code", + PayloadUUID: uuids.payload, + PayloadVersion: 1, + ...document, + }, + ], + PayloadDescription: "Configures T3 Code for every user on this Mac.", + PayloadDisplayName: `T3 Code policy for ${document.organization}`, + PayloadIdentifier: `com.t3tools.t3code.policy`, + PayloadOrganization: document.organization, + PayloadScope: "System", + PayloadType: "Configuration", + PayloadUUID: uuids.profile, + PayloadVersion: 1, + }; + return { + _tag: "Valid", + profile: ` + + +${plistValue(profile, "")} + +`, + }; +} diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index 16febe7aaaf3c..6017444e52a9f 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -46,11 +46,11 @@ Every user on the machine can read the policy, so do not put credentials in it. Use one of these per machine: -| OS | Native policy | Policy file | -| ------- | ------------------------------------------------------------------- | ------------------------------------------------- | -| macOS | Configuration profile for `com.t3tools.t3code` | `/Library/Application Support/T3Code/policy.json` | -| Windows | `Policy` string value under `HKLM\SOFTWARE\Policies\T3Tools\T3Code` | `C:\Program Files\T3Code\policy.json` | -| Linux | | `/etc/t3code/policy.json` | +| OS | Native policy | Policy file | +| ------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| macOS | Configuration profile for `com.t3tools.t3code` | `/Library/Application Support/T3Code/policy.json` | +| Windows | Group Policy template, or a `Policy` JSON value (both under `HKLM\SOFTWARE\Policies\T3Tools\T3Code`) | `C:\Program Files\T3Code\policy.json` | +| Linux | | `/etc/t3code/policy.json` | T3 Code does not merge sources. If a machine has both a native policy and a policy file, T3 Code refuses to start until you remove one. @@ -62,14 +62,28 @@ administrators. `com.t3tools.t3code`. The payload's keys are the policy's top-level keys (`schemaVersion`, `organization`, `supportUrl`, `settings`, `limits`); JSON objects become dictionaries and lists become arrays. To make the payload from a policy file you checked, run -`plutil -convert xml1 -o com.t3tools.t3code.plist policy.json` and upload the result. Install it as +`plutil -convert xml1 -o com.t3tools.t3code.plist policy.json` and upload the result, or run +`t3 policy templates --policy policy.json` to write a complete +`macos/t3code.mobileconfig`. Install it as a device profile, since T3 Code ignores user profiles, and deploy only one profile for this domain. A profile cannot hold `null`, so use a policy file if a setting's value must be `null`. -**Windows registry.** Set the `Policy` value (`REG_SZ`) to the whole policy as one line of JSON, +**Windows Group Policy.** Run `t3 policy templates ` and copy +`windows/t3code.admx` and `windows/en-US/t3code.adml` into your `PolicyDefinitions` folder or +central store, or import them into Intune. The policies appear under Computer Configuration > +Administrative Templates > T3 Code. Set **Organization** whenever you set any other T3 Code policy. +Locks and provider fields go in the two JSON policies, as the same objects a policy file would hold. +Machines pick up a change at the next Group Policy refresh; run `gpupdate /target:computer /force` +to apply it now, then restart T3 Code. +Setting **Allow T3 Connect**, **Allow webhook tasks**, **Allow importing browser cookies**, or +**Allow usage data** to **Disabled** turns that feature off. Setting any other T3 Code policy to +**Disabled** restricts nothing, the same as **Not Configured**. + +**Windows registry value.** Instead of the template, set the `Policy` value (`REG_SZ`) to the whole policy as one line of JSON, from Group Policy Preferences or an Intune script. T3 Code reads it through the Windows registry -API, so it works where registry tools such as `reg.exe` are blocked for users. In PowerShell, as an -administrator: +API, so it works where registry tools such as `reg.exe` are blocked for users. Use one or the other: +T3 Code refuses to start when the key holds both a `Policy` value and the template's settings. In +PowerShell, as an administrator: ```powershell $json = (Get-Content -Raw -Encoding UTF8 policy.json) -replace '\r?\n\s*', '' @@ -92,7 +106,8 @@ reopen the desktop app, or run `t3 service restart` for the [background service](./background-service.md). Reconnecting a phone or browser does not reload the policy. -To remove a policy, delete the file, remove the profile, or delete the registry value, then restart +To remove a policy, delete the file, remove the profile, set the Group Policy settings to +**Not Configured**, or delete the registry value, then restart the server. Users get back the settings they had chosen, since T3 Code never writes policy values into them. @@ -104,7 +119,9 @@ version does not support, or a machine with both a native policy and a file. The the policy came from and what is wrong. `t3 policy check` catches the same problems in a JSON file. For a profile or registry value, check -the JSON before you convert it. A policy written for a newer version can use keys an older one +the JSON before you convert it. For the Group Policy template, check a complete policy file that +holds the same settings, then paste its `settings` and `limits.providers.instances` objects into the +two JSON policies. A policy written for a newer version can use keys an older one cannot enforce, so upgrade T3 Code before you deploy a policy that uses them. ## Settings diff --git a/packages/shared/src/managedPolicyFile.test.ts b/packages/shared/src/managedPolicyFile.test.ts index f6cf7ef8c2cb6..2ab3ea710e3ec 100644 --- a/packages/shared/src/managedPolicyFile.test.ts +++ b/packages/shared/src/managedPolicyFile.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; @@ -12,6 +13,7 @@ import { MACOS_MANAGED_PREFERENCES_PATH, managedPolicyPath, readManagedPolicy, + type RegistryKey, WINDOWS_POLICY_KEY, layerWindowsRegistry, WindowsRegistry, @@ -23,8 +25,10 @@ const policy = JSON.stringify({ schemaVersion: 1, organization: "Example Corp" } /** What a command prints and exits with. */ type Result = { readonly stdout: string; readonly exitCode: number }; -/** The registry's `Policy` value: its text, absent, or the Win32 error reading it fails with. */ -type RegistryAnswer = string | undefined | { readonly code?: number }; +/** Keys below the policy key ("" for the key itself), or the error every read fails with. */ +type RegistryAnswer = Readonly> | WindowsRegistryError; +const isRegistryError = Schema.is(WindowsRegistryError); +const POLICY_SUBKEY = WINDOWS_POLICY_KEY.slice("HKLM\\".length); /** Files that exist, what a command prints and exits with by command name, and the registry. */ const machine = ( @@ -36,10 +40,10 @@ const machine = ( Layer.succeed( WindowsRegistry, WindowsRegistry.of({ - readMachineString: () => - typeof registry === "object" - ? Effect.fail(new WindowsRegistryError(registry)) - : Effect.succeed(registry), + readMachineKey: (subkey) => + isRegistryError(registry) + ? Effect.fail(registry) + : Effect.succeed(registry[subkey.slice(POLICY_SUBKEY.length + 1)]), }), ), FileSystem.layerNoop({ @@ -70,11 +74,34 @@ const machine = ( ), ); +/** A policy key holding `values`, with checkbox subkeys of `name=0|1` entries. */ +const policyKey = ( + values: RegistryKey["values"], + subkeys: Readonly>>> = {}, +): Readonly> => ({ + "": { values, subkeys: Object.keys(subkeys) }, + ...Object.fromEntries( + Object.entries(subkeys).map(([name, boxes]) => [ + name, + { + values: Object.entries(boxes).map(([box, data]) => ({ + name: box, + type: "REG_DWORD" as const, + data, + })), + subkeys: [], + }, + ]), + ), +}); +const text = (name: string, data: string) => ({ name, type: "REG_SZ" as const, data }); +const dword = (name: string, data: number) => ({ name, type: "REG_DWORD" as const, data }); + const read = ( platform: NodeJS.Platform, files: Readonly>, commands: Parameters[1] = {}, - registry: RegistryAnswer = undefined, + registry: RegistryAnswer = {}, ) => readManagedPolicy(platform).pipe( Effect.map((found) => found?.source), @@ -139,52 +166,189 @@ describe("readManagedPolicy", () => { ); it.effect( - "reads the Windows Policies registry value, and treats only an absent one as none", + "reads a Policy value of JSON from the registry, and treats only an absent key as none", () => Effect.gen(function* () { const file = { [managedPolicyPath("win32")]: policy }; const win32 = (files: Readonly>, registry: RegistryAnswer) => read("win32", files, {}, registry); + const json = policyKey([text("Policy", policy)]); - assert.equal(yield* win32({}, policy), WINDOWS_POLICY_KEY); - assert.isUndefined(yield* win32({}, undefined)); - assert.equal(yield* win32(file, undefined), managedPolicyPath("win32")); - assert.include(yield* win32(file, policy), "deploy the policy one way"); + assert.equal(yield* win32({}, json), WINDOWS_POLICY_KEY); + assert.isUndefined(yield* win32({}, {})); + assert.equal(yield* win32(file, {}), managedPolicyPath("win32")); + assert.include(yield* win32(file, json), "deploy the policy one way"); // A read that fails, here access denied, is not read as no policy, file or not. for (const files of [{}, file]) { assert.include( - yield* win32(files, { code: 5 }), + yield* win32(files, new WindowsRegistryError({ code: 5 })), "the registry cannot be read (Windows error 5)", ); } - assert.include(yield* win32({}, {}), "cannot load its registry reader; reinstall T3 Code"); assert.include( - yield* win32({}, { code: 1630 }), + yield* win32({}, new WindowsRegistryError({})), + "cannot load its registry reader; reinstall T3 Code", + ); + assert.include( + yield* win32({}, policyKey([{ name: "Policy", type: "other" }])), "its Policy value is not a string (REG_SZ)", ); - assert.include(yield* win32({}, "{ not json"), "does not match the policy schema"); + assert.include( + yield* win32({}, policyKey([text("Policy", "{ not json")])), + "does not match the policy schema", + ); + for (const both of [ + policyKey([text("Policy", policy), text("Organization", "Example")]), + policyKey([text("Policy", policy)], { Clients: { desktop: 1 } }), + ]) { + assert.include(yield* win32({}, both), "both a Policy value and Group Policy settings"); + } + // Not Configured clears a list policy's values but can leave its empty subkey behind. + assert.equal( + yield* win32({}, policyKey([text("Policy", policy)], { Clients: {} })), + WINDOWS_POLICY_KEY, + ); }), ); + it.effect("builds the policy from the values the Group Policy template writes", () => + Effect.gen(function* () { + const organization = text("Organization", "Société 日本 ✓"); + const document = (registry: RegistryAnswer) => + readManagedPolicy("win32", ["codex", "claudeAgent"]).pipe( + Effect.map((found): unknown => found?.document), + Effect.catchTags({ + ManagedPolicyFileError: (error) => Effect.succeed(`error: ${error.reason}`), + }), + Effect.provide(machine({}, {}, registry)), + ); + + assert.deepStrictEqual( + yield* document( + policyKey( + [ + organization, + text("SupportUrl", ""), + // Registry names ignore case. + text("maxRuntimeMode", "auto-accept-edits"), + dword("Telemetry", 0), + text("Settings", '{"enableAgentBrowserAccess": {"value": false, "locked": true}}'), + text("UpdatesMode", "off"), + dword("AllowNightly", 1), + dword("Updates", 1), + dword("Clients", 1), + dword("AllowedDrivers", 1), + ], + // Registry names ignore case, so a subkey written in other case still counts. + { + clients: { Desktop: 1, "LOCAL-BROWSER": 1, REMOTE: 1 }, + AllowedDrivers: { Codex: 1, CLAUDEAGENT: 1, UnknownDriver: 1 }, + }, + ), + ), + { + schemaVersion: 1, + organization: "Société 日本 ✓", + settings: { enableAgentBrowserAccess: { value: false, locked: true } }, + limits: { + maxRuntimeMode: "auto-accept-edits", + telemetry: false, + updates: { mode: "off", allowNightly: true }, + clients: ["desktop", "local-browser", "remote"], + providers: { allowedDrivers: ["codex", "claudeAgent", "UnknownDriver"] }, + }, + }, + ); + + // Disabling a policy leaves each checkbox's falseValue behind; it restricts nothing. + const disabledGroups = { + Clients: { desktop: 0, "local-browser": 0, remote: 0 }, + AllowedDrivers: { codex: 0, claudeAgent: 0 }, + }; + assert.deepStrictEqual( + yield* document( + policyKey( + [ + organization, + dword("Clients", 0), + dword("AllowedDrivers", 0), + dword("Updates", 0), + dword("AllowNightly", 0), + dword("Telemetry", 0), + ], + disabledGroups, + ), + ), + { schemaVersion: 1, organization: "Société 日本 ✓", limits: { telemetry: false } }, + ); + // With nothing else configured, that is no policy rather than one missing Organization. + assert.isUndefined( + yield* document( + policyKey([dword("AllowedDrivers", 0)], { + AllowedDrivers: disabledGroups.AllowedDrivers, + }), + ), + ); + assert.isUndefined(yield* document(policyKey([]))); + + // Anything the template does not write fails closed rather than being ignored. + for (const [registry, reason] of [ + [policyKey([organization, dword("Telemetry", 2)]), "DWORD of 0 or 1"], + [policyKey([organization, text("MaxMode", "auto")]), "not a T3 Code policy value"], + [policyKey([organization], { Other: { x: 1 } }), "Other is not a T3 Code policy key"], + [ + { + ...policyKey([organization, dword("Clients", 1)], { Clients: { desktop: 1 } }), + Clients: { values: [dword("desktop", 1)], subkeys: ["Nested"] }, + }, + "Clients\\Nested is not a T3 Code policy key", + ], + [policyKey([organization, text("Settings", "{")]), "not valid JSON"], + [policyKey([organization, dword("Clients", 2)]), "DWORD of 0 or 1"], + [ + policyKey([organization, dword("Clients", 1)], { Clients: { UnknownClient: 1 } }), + "does not match the policy schema", + ], + [ + policyKey([organization, dword("Clients", 1)], { Clients: { desktop: 0 } }), + "does not match the policy schema", + ], + [policyKey([dword("Organization", 1)]), "Organization must be a string"], + // REG_EXPAND_SZ would fill in the reading user's variables. + [policyKey([{ name: "Organization", type: "other" }]), "Organization must be a string"], + [ + policyKey([text("MaxRuntimeMode", "auto-accept-edits")]), + "set the Organization policy whenever another T3 Code policy is set", + ], + ] as const) { + assert.include(yield* document(registry), reason); + } + }), + ); + it.effect.runIf(HostProcess.Platform.defaultValue() === "win32")( - "reads REG_SZ values through the Win32 API, and refuses other types", + "lists registry values and subkeys through the Win32 API", () => Effect.gen(function* () { const registry = yield* WindowsRegistry; - const version = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"; - assert.match((yield* registry.readMachineString(version, "ProductName")) ?? "", /Windows/); - assert.isUndefined(yield* registry.readMachineString(version, "T3CodeNoSuchValue")); - assert.isUndefined( - yield* registry.readMachineString("SOFTWARE\\T3CodeNoSuchKey", "Policy"), + const version = yield* registry.readMachineKey( + "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", ); - // REG_DWORD, and REG_EXPAND_SZ, which would otherwise expand the user's variables. - for (const [key, value] of [ - [version, "CurrentMajorVersionNumber"], - ["SOFTWARE\\Microsoft\\Windows\\CurrentVersion", "ProgramFilesPath"], - ] as const) { - const error = yield* registry.readMachineString(key, value).pipe(Effect.flip); - assert.equal(error.code, 1630); - } + const value = (name: string) => version?.values.find((entry) => entry.name === name); + const product = value("ProductName"); + assert.match(product?.type === "REG_SZ" ? product.data : "", /Windows/); + assert.equal(value("CurrentMajorVersionNumber")?.type, "REG_DWORD"); + assert.isUndefined(yield* registry.readMachineKey("SOFTWARE\\T3CodeNoSuchKey")); + const windows = yield* registry.readMachineKey( + "SOFTWARE\\Microsoft\\Windows\\CurrentVersion", + ); + // REG_EXPAND_SZ, which would otherwise expand the user's variables. + assert.equal( + windows?.values.find((entry) => entry.name === "ProgramFilesPath")?.type, + "other", + ); + const parent = yield* registry.readMachineKey("SOFTWARE\\Microsoft\\Windows NT"); + assert.isTrue(parent?.subkeys.includes("CurrentVersion")); }).pipe(Effect.provide(layerWindowsRegistry(() => import("ffi-rs")))), ); }); diff --git a/packages/shared/src/managedPolicyFile.ts b/packages/shared/src/managedPolicyFile.ts index 12fb5a666a04a..e6faf3b711239 100644 --- a/packages/shared/src/managedPolicyFile.ts +++ b/packages/shared/src/managedPolicyFile.ts @@ -1,4 +1,8 @@ -import { ManagedPolicyDocument, SERVER_EXIT_CODE_MANAGED_POLICY } from "@t3tools/contracts"; +import { + ManagedPolicyClient, + ManagedPolicyDocument, + SERVER_EXIT_CODE_MANAGED_POLICY, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -94,8 +98,41 @@ export const readManagedPolicyFile = Effect.fn("readManagedPolicyFile")(function export const MACOS_MANAGED_PREFERENCES_PATH = "/Library/Managed Preferences/com.t3tools.t3code.plist"; const WINDOWS_POLICY_SUBKEY = "SOFTWARE\\Policies\\T3Tools\\T3Code"; -/** The registry key Group Policy and Intune write to on Windows; `Policy` holds the JSON. */ +/** + * The registry key Group Policy and Intune write to on Windows. It holds either the Group Policy + * template's values, one per control, or a `Policy` string with the whole policy as JSON. + */ export const WINDOWS_POLICY_KEY = `HKLM\\${WINDOWS_POLICY_SUBKEY}`; + +/** Each value a Group Policy control writes, and where it lands in the document. */ +export const WINDOWS_POLICY_VALUES = { + Organization: { type: "string", path: ["organization"] }, + SupportUrl: { type: "string", path: ["supportUrl"] }, + Settings: { type: "json", path: ["settings"] }, + MaxRuntimeMode: { type: "string", path: ["limits", "maxRuntimeMode"] }, + Connect: { type: "boolean", path: ["limits", "connect"] }, + WebhookTasks: { type: "boolean", path: ["limits", "webhookTasks"] }, + Telemetry: { type: "boolean", path: ["limits", "telemetry"] }, + BrowserCookieImport: { type: "boolean", path: ["limits", "browserCookieImport"] }, + ProviderInstances: { type: "json", path: ["limits", "providers", "instances"] }, + UpdatesMode: { type: "string", path: ["limits", "updates", "mode"] }, + UpdatesFeedUrl: { type: "string", path: ["limits", "updates", "feedUrl"] }, + AllowNightly: { type: "boolean", path: ["limits", "updates", "allowNightly"] }, +} as const satisfies Record< + string, + { readonly type: "string" | "json" | "boolean"; readonly path: ReadonlyArray } +>; + +/** Subkeys of checkboxes: every value set to 1 is one entry of the list at `path`. */ +export const WINDOWS_POLICY_LISTS = { + Clients: ["limits", "clients"], + AllowedDrivers: ["limits", "providers", "allowedDrivers"], +} as const satisfies Record>; +// A disabled policy still writes its checkboxes' falseValue; its own DWORD disambiguates it. +export const WINDOWS_POLICY_GROUPS = { + ...WINDOWS_POLICY_LISTS, + Updates: ["limits", "updates"], +} as const satisfies Record>; // An absolute path: an earlier PATH entry could otherwise answer that there is no policy. const PLUTIL = "/usr/bin/plutil"; @@ -112,30 +149,50 @@ export class WindowsRegistryError extends Schema.TaggedError; + readonly subkeys: ReadonlyArray; +} + /** - * Reads the registry through the Win32 API in this process. A missing value and a failed read - * come back apart, and blocked registry tools such as reg.exe do not matter. + * Reads the registry through the Win32 API in this process. A missing key and a failed read come + * back apart, and blocked registry tools such as reg.exe do not matter. */ export class WindowsRegistry extends Context.Service< WindowsRegistry, { - /** The REG_SZ `value` of HKLM\\`subkey` in the 64-bit view; undefined when either is absent. */ - readonly readMachineString: ( + /** The values and subkey names of HKLM\\`subkey` in the 64-bit view; undefined when absent. */ + readonly readMachineKey: ( subkey: string, - value: string, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/shared/managedPolicyFile/WindowsRegistry") {} // (HKEY)(LONG)0x80000002, sign-extended to 64 bits. const HKEY_LOCAL_MACHINE = -0x7ffffffen; -const RRF_RT_REG_SZ = 0x2; -const RRF_SUBKEY_WOW6464KEY = 0x10000; -// Without it a REG_EXPAND_SZ value passes as REG_SZ, with the user's variables filled in. -const RRF_NOEXPAND = 0x10000000; +const KEY_READ = 0x20019; +const KEY_WOW64_64KEY = 0x100; +const REG_SZ = 1; +const REG_DWORD = 4; const ERROR_FILE_NOT_FOUND = 2; const ERROR_MORE_DATA = 234; -const ERROR_UNSUPPORTED_TYPE = 1630; +const ERROR_NO_MORE_ITEMS = 259; +// Value and key names are at most 16,383 characters. +const MAX_NAME_CHARS = 16_384; + +class Win32Failure extends Error { + readonly code: number; + constructor(code: number) { + super(`Windows error ${code}`); + this.code = code; + } +} /** * Each app passes how it loads ffi-rs: a server single-executable resolves packages on disk only @@ -146,67 +203,129 @@ export const layerWindowsRegistry = (loadFfi: () => Promise { - // HKEY_LOCAL_MACHINE and the pointer arguments below are 64-bit. + // HKEY_LOCAL_MACHINE, key handles, and the null pointers below are 64-bit. if (architecture !== "x64" && architecture !== "arm64") { throw new Error(`no registry access on ${architecture}`); } const { DataType, load, open } = await loadFfi(); open({ library: "t3-advapi32", path: "advapi32.dll" }); - return (subkey: string, value: string, data: Buffer, size: Buffer): number => + const call = ( + funcName: string, + paramsType: Array<(typeof DataType)[keyof typeof DataType]>, + paramsValue: Array, + ): number => load({ library: "t3-advapi32", - funcName: "RegGetValueW", + funcName, retType: DataType.I32, - paramsType: [ - DataType.BigInt, - DataType.WString, - DataType.WString, - DataType.U32, - DataType.BigInt, - DataType.U8Array, - DataType.U8Array, - ], - paramsValue: [ - HKEY_LOCAL_MACHINE, - subkey, - value, - RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY | RRF_NOEXPAND, - 0n, - data, - size, - ], - }); + paramsType, + paramsValue, + } as Parameters[0]) as number; + const { BigInt: Handle, U32, U8Array: Out, WString } = DataType; + return { + open: (subkey: string, handle: Buffer) => + call( + "RegOpenKeyExW", + [Handle, WString, U32, U32, Out], + [HKEY_LOCAL_MACHINE, subkey, 0, KEY_READ | KEY_WOW64_64KEY, handle], + ), + enumValue: ( + key: bigint, + index: number, + name: Buffer, + nameChars: Buffer, + type: Buffer, + data: Buffer, + dataBytes: Buffer, + ) => + call( + "RegEnumValueW", + [Handle, U32, Out, Out, Handle, Out, Out, Out], + [key, index, name, nameChars, 0n, type, data, dataBytes], + ), + enumKey: (key: bigint, index: number, name: Buffer, nameChars: Buffer) => + call( + "RegEnumKeyExW", + [Handle, U32, Out, Out, Handle, Handle, Handle, Handle], + [key, index, name, nameChars, 0n, 0n, 0n, 0n], + ), + close: (key: bigint) => call("RegCloseKey", [Handle], [key]), + }; }, catch: (cause) => new WindowsRegistryError({ cause }), }), ); + + const readKey = (api: Effect.Success, subkey: string) => { + const handle = Buffer.alloc(8); + const opened = api.open(subkey, handle); + if (opened === ERROR_FILE_NOT_FOUND) return undefined; + if (opened !== 0) throw new Win32Failure(opened); + const key = handle.readBigInt64LE(); + try { + const name = Buffer.alloc(MAX_NAME_CHARS * 2); + const chars = Buffer.alloc(4); + const values: Array = []; + for (let index = 0; ; index++) { + let data = Buffer.alloc(64 * 1024); + const type = Buffer.alloc(4); + const bytes = Buffer.alloc(4); + const attempt = () => { + chars.writeUInt32LE(MAX_NAME_CHARS); + bytes.writeUInt32LE(data.length); + return api.enumValue(key, index, name, chars, type, data, bytes); + }; + let code = attempt(); + // The value is larger than the buffer; the API reported the size it needs. + if (code === ERROR_MORE_DATA) { + data = Buffer.alloc(bytes.readUInt32LE()); + code = attempt(); + } + if (code === ERROR_NO_MORE_ITEMS) break; + if (code !== 0) throw new Win32Failure(code); + const valueName = name.subarray(0, chars.readUInt32LE() * 2).toString("utf16le"); + const stored = data.subarray(0, bytes.readUInt32LE()); + const valueType = type.readUInt32LE(); + values.push( + valueType === REG_SZ + ? { + name: valueName, + type: "REG_SZ", + data: stored.toString("utf16le").replace(/\0+$/, ""), + } + : valueType === REG_DWORD && stored.length === 4 + ? { name: valueName, type: "REG_DWORD", data: stored.readUInt32LE() } + : { name: valueName, type: "other" }, + ); + } + const subkeys: Array = []; + for (let index = 0; ; index++) { + chars.writeUInt32LE(MAX_NAME_CHARS); + const code = api.enumKey(key, index, name, chars); + if (code === ERROR_NO_MORE_ITEMS) break; + if (code !== 0) throw new Win32Failure(code); + subkeys.push(name.subarray(0, chars.readUInt32LE() * 2).toString("utf16le")); + } + return { values, subkeys }; + } finally { + api.close(key); + } + }; + return WindowsRegistry.of({ - readMachineString: Effect.fn("WindowsRegistry.readMachineString")( - function* (subkey, value) { - const getValue = yield* loadGetValue; - const attempt = (bytes: number) => - Effect.try({ - try: () => { - const data = Buffer.alloc(bytes); - const size = Buffer.alloc(4); - size.writeUInt32LE(bytes); - const code = getValue(subkey, value, data, size); - return { code, data, bytes: size.readUInt32LE() }; - }, - catch: (cause) => new WindowsRegistryError({ cause }), - }); - let result = yield* attempt(64 * 1024); - // The value grew past the buffer; the API reported the size it needs. - if (result.code === ERROR_MORE_DATA) result = yield* attempt(result.bytes); - if (result.code === ERROR_FILE_NOT_FOUND) return undefined; - if (result.code !== 0) return yield* new WindowsRegistryError({ code: result.code }); - // The size counts the terminating null RegGetValueW adds. - return result.data.subarray(0, result.bytes).toString("utf16le").replace(/\0+$/, ""); - }, - ), + readMachineKey: Effect.fn("WindowsRegistry.readMachineKey")(function* (subkey) { + const api = yield* loadApi; + return yield* Effect.try({ + try: () => readKey(api, subkey), + catch: (cause) => + new WindowsRegistryError( + cause instanceof Win32Failure ? { code: cause.code, cause } : { cause }, + ), + }); + }), }); }), ); @@ -229,6 +348,115 @@ const run = Effect.fnUntraced( ); const decodeJsonExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +type PolicyRegistryValue = RegistryValue & { + /** The list subkey the value sits in, or "" for the policy key itself. */ + readonly key: string; +}; + +const setPath = (target: Record, path: ReadonlyArray, value: unknown) => { + let node = target; + for (const segment of path.slice(0, -1)) { + node = (node[segment] ??= {}) as Record; + } + node[path.at(-1)!] = value; +}; + +const dwordBoolean = (value: RegistryValue) => + value.type !== "REG_DWORD" + ? undefined + : value.data === 0 + ? false + : value.data === 1 + ? true + : undefined; + +/** + * The document the Group Policy template's values describe, undefined when they describe none, or + * why they do not describe one. + */ +function windowsPolicyDocument( + values: ReadonlyArray, + knownDrivers: ReadonlyArray, +): { readonly document: unknown } | { readonly error: string } { + // Registry names ignore case; a hand-written `organization` is still Organization. + const canonical = new Map( + [...Object.keys(WINDOWS_POLICY_VALUES), ...Object.keys(WINDOWS_POLICY_GROUPS)].map((name) => [ + name.toLowerCase(), + name, + ]), + ); + values = values.map((value) => + value.key === "" + ? { ...value, name: canonical.get(value.name.toLowerCase()) ?? value.name } + : value, + ); + const document: Record = { schemaVersion: 1 }; + const lists = new Map>(); + const disabled: Array> = []; + for (const value of values) { + if (value.key !== "" || !Object.hasOwn(WINDOWS_POLICY_GROUPS, value.name)) continue; + const enabled = dwordBoolean(value); + if (enabled === undefined) return { error: `${value.name} must be a DWORD of 0 or 1` }; + if (!enabled) { + disabled.push(WINDOWS_POLICY_GROUPS[value.name as keyof typeof WINDOWS_POLICY_GROUPS]); + } else if (Object.hasOwn(WINDOWS_POLICY_LISTS, value.name)) { + lists.set(value.name, []); + } + } + const put = (path: ReadonlyArray, value: unknown) => { + if (!disabled.some((prefix) => prefix.every((segment, index) => path[index] === segment))) { + setPath(document, path, value); + } + }; + for (const value of values) { + const where = value.key === "" ? value.name : `${value.key}\\${value.name}`; + if (value.key !== "") { + const checked = dwordBoolean(value); + if (checked === undefined) return { error: `${where} must be a DWORD of 0 or 1` }; + const list = lists.get(value.key) ?? []; + lists.set(value.key, list); + if (checked) { + const names = value.key === "Clients" ? ManagedPolicyClient.literals : knownDrivers; + list.push( + names.find((name) => name.toLowerCase() === value.name.toLowerCase()) ?? value.name, + ); + } + continue; + } + if (Object.hasOwn(WINDOWS_POLICY_GROUPS, value.name)) continue; + if (!Object.hasOwn(WINDOWS_POLICY_VALUES, value.name)) { + return { error: `${value.name} is not a T3 Code policy value` }; + } + const field = WINDOWS_POLICY_VALUES[value.name as keyof typeof WINDOWS_POLICY_VALUES]; + if (field.type === "boolean") { + const flag = dwordBoolean(value); + if (flag === undefined) return { error: `${where} must be a DWORD of 0 or 1` }; + put(field.path, flag); + continue; + } + if (value.type !== "REG_SZ") return { error: `${where} must be a string (REG_SZ)` }; + // Group Policy writes an empty string for a box left blank. + if (value.data === "") continue; + if (field.type === "string") { + put(field.path, value.data); + continue; + } + const parsed = decodeJsonExit(value.data); + if (Exit.isFailure(parsed)) return { error: `${where} is not valid JSON` }; + put(field.path, parsed.value); + } + for (const [key, list] of lists) { + put(WINDOWS_POLICY_LISTS[key as keyof typeof WINDOWS_POLICY_LISTS], list); + } + // Policies set to Disabled leave values behind that restrict nothing. + if (Object.keys(document).length === 1) return { document: undefined }; + // The schema would name `["organization"]`, which an admin cannot map to a Group Policy setting. + if (document.organization === undefined) { + return { error: "set the Organization policy whenever another T3 Code policy is set" }; + } + return { document }; +} + // Strict: keys beside the wrapper, or more than one entry, decode as a plain document and fail. const decodeForcedExit = Schema.decodeUnknownExit( Schema.Struct({ @@ -261,7 +489,10 @@ function unwrapForced(json: string): unknown { } /** Read the policy a configuration profile or Group Policy set; absent means no such policy. */ -const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platform) { +const readPlatformPolicy = Effect.fnUntraced(function* ( + platform: NodeJS.Platform, + knownDrivers: ReadonlyArray, +) { if (platform === "darwin") { const path = MACOS_MANAGED_PREFERENCES_PATH; const exists = yield* (yield* FileSystem.FileSystem) @@ -286,26 +517,61 @@ const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platfor } if (platform === "win32") { const path = WINDOWS_POLICY_KEY; - const raw = yield* (yield* WindowsRegistry) - .readMachineString(WINDOWS_POLICY_SUBKEY, "Policy") - .pipe( + const registry = yield* WindowsRegistry; + const readKey = (subkey: string) => + registry.readMachineKey(subkey).pipe( Effect.mapError( (cause) => new ManagedPolicyFileError({ path, reason: - cause.code === ERROR_UNSUPPORTED_TYPE - ? "its Policy value is not a string (REG_SZ)" - : cause.code === undefined - ? "this T3 Code install cannot load its registry reader; reinstall T3 Code" - : `the registry cannot be read (${cause.message})`, + cause.code === undefined + ? "this T3 Code install cannot load its registry reader; reinstall T3 Code" + : `the registry cannot be read (${cause.message})`, cause, }), ), ); - return raw === undefined - ? undefined - : { source: path, document: yield* decodeDocument(path, raw) }; + const refuse = (reason: string) => new ManagedPolicyFileError({ path, reason }); + const root = yield* readKey(WINDOWS_POLICY_SUBKEY); + if (root === undefined) return undefined; + const json = root.values.find((value) => value.name.toLowerCase() === "policy"); + if (json !== undefined) { + // Not Configured clears a list policy's values but can leave its empty subkey behind. + let templateSettings = root.values.length > 1; + for (const subkey of root.subkeys) { + if (templateSettings) break; + const child = yield* readKey(`${WINDOWS_POLICY_SUBKEY}\\${subkey}`); + templateSettings = + child !== undefined && (child.values.length > 0 || child.subkeys.length > 0); + } + if (templateSettings) { + return yield* refuse( + "it holds both a Policy value and Group Policy settings; deploy the policy one way", + ); + } + if (json.type !== "REG_SZ") return yield* refuse("its Policy value is not a string (REG_SZ)"); + return { source: path, document: yield* decodeDocument(path, json.data) }; + } + const values: Array = root.values.map((value) => ({ ...value, key: "" })); + for (const subkey of root.subkeys) { + // Registry names ignore case. + const list = Object.keys(WINDOWS_POLICY_LISTS).find( + (name) => name.toLowerCase() === subkey.toLowerCase(), + ); + if (list === undefined) return yield* refuse(`${subkey} is not a T3 Code policy key`); + const checkboxes = yield* readKey(`${WINDOWS_POLICY_SUBKEY}\\${subkey}`); + if (checkboxes === undefined) continue; + if (checkboxes.subkeys.length > 0) { + return yield* refuse(`${subkey}\\${checkboxes.subkeys[0]} is not a T3 Code policy key`); + } + values.push(...checkboxes.values.map((value) => ({ ...value, key: list }))); + } + const built = windowsPolicyDocument(values, knownDrivers); + if ("error" in built) return yield* refuse(built.error); + // An empty key, or one holding only policies set to Disabled, sets nothing. + if (built.document === undefined) return undefined; + return { source: path, document: yield* decodeDocument(path, built.document) }; } return undefined; }); @@ -317,10 +583,11 @@ const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platfor */ export const readManagedPolicy = Effect.fn("readManagedPolicy")(function* ( platform: NodeJS.Platform, + knownDrivers: ReadonlyArray = [], ) { const filePath = managedPolicyPath(platform); const [platformPolicy, file] = yield* Effect.all([ - readPlatformPolicy(platform), + readPlatformPolicy(platform, knownDrivers), readManagedPolicyFile(filePath), ]); if (platformPolicy !== undefined && file !== undefined) {