diff --git a/docs/managed-workspace-policy.md b/docs/managed-workspace-policy.md index 0b82a6be8..faa188b52 100644 --- a/docs/managed-workspace-policy.md +++ b/docs/managed-workspace-policy.md @@ -2,7 +2,8 @@ `ug configure` reads the workspace's published `CodingAgentConfig` through `GET /api/ai-gateway/v2/coding-agent-configs` and installs it at -`/etc/unitygateway/managed-settings.json` on Linux/macOS. +`/etc/unitygateway/managed-settings.json` on Linux/macOS or +`%ProgramData%\UnityGateway\managed-settings.json` on Windows. The file contains only: @@ -16,7 +17,10 @@ The file contains only: `config` is the raw remote configuration, not normalized agent settings. The empty object above illustrates the shape only: a workspace without policy does not create a policy file. The installed directory is root-owned `0755`; the policy is root-owned `0644`, so ordinary -users can read without sudo. Authentication tokens and profiles remain in user-owned state. +users can read without sudo. +On Windows the `UnityGateway` folder is owned by Administrators (SYSTEM and Administrators have +full control, standard users read and execute only), and the file inherits that access, so only an +elevated process can change it. ug refuses to write through a linked folder. Authentication tokens and profiles remain in user-owned state. Administrator-authored policy fields, including configured headers, are visible to all local users. **Never put secrets in managed policy headers.** The current schema accepts arbitrary header @@ -89,23 +93,26 @@ All other raw fields participate in comparison. Policy is otherwise preserved wh ## Migration and limits -On Linux and macOS, the per-user `~/.ucode/managed-config.json` is no longer read and is never +On Linux, macOS, and Windows, the per-user `~/.ucode/managed-config.json` is no longer read and is never promoted to a root-owned policy. Run `ug configure --workspace YOUR_WORKSPACE` to retrieve and install authoritative policy. -`ug revert` removes the installed policy with sudo; without sudo it warns and keeps the file. +`ug revert` removes the installed policy with sudo (Windows: from an elevated terminal); without +them it warns and keeps the file. No policy is written when the workspace has none. On MDM machines, ask IT to run root provisioning instead of attempting administrator-only configure yourself. The five-minute TTL avoids every-launch request latency but delays detection of remote changes. Warning/fallback prioritizes availability over strict enforcement of the newest policy. -Privileged writes are supported on Linux/macOS only. Real sudo/password and ownership integration -checks require a disposable clean machine, not an existing developer-managed workstation. +Windows has no sudo: only an elevated terminal (Run as administrator) can run `ug configure` or +`ug revert` against the policy, and a standard-user `ug configure` fails with that hint. A launch +never elevates; like root on Linux and macOS, an already-elevated launch may update the file. The +Windows folder permissions are applied with `icacls` and are not yet verified on a real Windows +host. Real sudo/password, `icacls`, and ownership integration checks require a disposable clean machine, not an existing developer-managed workstation. -Where the machine cannot hold the policy (Windows, `UG_SKIP_OS_SETTINGS=1`, or `ug configure +Where the machine cannot hold the policy (`UG_SKIP_OS_SETTINGS=1` or `ug configure --user`) and none is installed for the workspace, ug keeps its earlier behavior unchanged: the fetched policy is cached per user in `~/.ucode/managed-config.json`, a launch reuses it for five -minutes, and a failed fetch falls back to it. Windows support for the installed policy is a -follow-up. A non-root Linux or macOS launch with nothing installed applies the fetched policy to +minutes, and a failed fetch falls back to it. A non-root, non-elevated launch with nothing installed applies the fetched policy to that run without installing or caching it, so a first launch before any `ug configure` is still managed. An installed policy still wins: with `UG_SKIP_OS_SETTINGS=1` a developer's launch reads it read-only, never writes OS-managed files, and builds only user-owned diff --git a/scripts/run_integration.py b/scripts/run_integration.py index c40493304..933822be1 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -77,12 +77,14 @@ def managed_policy_paths( selected = set(agents) paths: list[Path] = [] if platform_name == "nt": + program_data = environment.get("PROGRAMDATA", "").strip() + if not program_data: + raise RuntimeError( + "Cannot verify Unity Gateway workspace policy because PROGRAMDATA is unavailable." + ) + # Unity Gateway's installed workspace policy affects every agent. + paths.append(Path(program_data) / "UnityGateway" / "managed-settings.json") if "codex" in selected: - program_data = environment.get("PROGRAMDATA", "").strip() - if not program_data: - raise RuntimeError( - "Cannot verify Codex system policy because PROGRAMDATA is unavailable." - ) codex_system = Path(program_data) / "OpenAI" / "Codex" paths.extend([codex_system / "requirements.toml", codex_system / "config.toml"]) if "claude" in selected: diff --git a/specs/features/machine-managed-policy.md b/specs/features/machine-managed-policy.md index bc766f769..0b26166cc 100644 --- a/specs/features/machine-managed-policy.md +++ b/specs/features/machine-managed-policy.md @@ -7,7 +7,7 @@ created: 2026-10-12 ## Intent -Install workspace policy in `/etc/unitygateway/managed-settings.json` during `ug configure` and read it during agent launches. Preserve five-minute launch refreshes without rewriting the privileged file or requesting sudo when policy content is unchanged. +Install workspace policy in `/etc/unitygateway/managed-settings.json` (Windows: `%ProgramData%\UnityGateway\managed-settings.json`) during `ug configure` and read it during agent launches. Preserve five-minute launch refreshes without rewriting the privileged file or requesting sudo when policy content is unchanged. ## Context @@ -40,7 +40,7 @@ The existing privileged writer atomically installs allowlisted Claude/Codex sett ## Clarifications - Q1: Which platforms are supported? - A1: Linux and macOS use `/etc/unitygateway/managed-settings.json`. Unsupported-platform writes fail explicitly; do not introduce a user-cache policy fallback. + A1: Linux and macOS use `/etc/unitygateway/managed-settings.json`; Windows uses `%ProgramData%\UnityGateway\managed-settings.json`, written only by an elevated process into an Administrators-owned folder (standard users read only; no sudo, so a non-elevated configure fails with an administrator hint, and revert warns and keeps the file). Unsupported-platform writes fail explicitly; do not introduce a user-cache policy fallback. - Q2: How many workspaces does the machine file hold? A2: One active workspace. Explicit administrator configure can replace it for all users. Launch refuses a mismatched workspace and never uses unmanaged fallback to escape the installed policy. - Q3: What if sudo is unavailable? diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index 9612c6fee..a4f116c17 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -713,9 +713,13 @@ def revert_machine_policy() -> str: try: update_workspace_policy(MANAGED_CONFIG_PATH, None, interactive=True, allow_sudo=True) except RuntimeError as exc: + retry = ( + "Rerun `ug revert` from an elevated terminal (Run as administrator)." + if current_os() is OS.WINDOWS + else "Rerun `ug revert` from a terminal where you can use sudo." + ) print_warning( - f"Could not remove the workspace policy at {MANAGED_CONFIG_PATH}: {exc} Rerun " - "`ug revert` from a terminal where you can use sudo." + f"Could not remove the workspace policy at {MANAGED_CONFIG_PATH}: {exc} {retry}" ) return "failed" return "removed" @@ -762,8 +766,8 @@ def load_managed_configuration(workspace: str | None) -> dict | None: def _installed_config(workspace: str) -> dict | None: """The installed machine policy for ``workspace``, read only where ug installs one. - On Windows the POSIX path resolves under the current drive's root, which a standard user may be - able to create, so it is never trusted there. + On Windows only the protected ProgramData file is read, never a POSIX-style path under a drive + root that a standard user may be able to create. """ if not _machine_policy_supported(): return None @@ -932,10 +936,18 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag ) except RuntimeError as exc: if force_refresh and _machine_policy_installable(): + if current_os() is OS.WINDOWS: + hint = ( + "Run `ug configure` from an elevated terminal (Run as administrator), or ask " + "your IT team or an administrator to run it." + ) + else: + hint = ( + "This machine-wide file needs sudo. Rerun `ug configure` from a terminal where " + "you can use sudo, or ask your IT team or an administrator to run it." + ) message = ( - f"Could not install the workspace policy at {MANAGED_CONFIG_PATH}: {exc} " - "This machine-wide file needs sudo. Rerun `ug configure` from a terminal where you " - "can use sudo, or ask your IT team or an administrator to run it." + f"Could not install the workspace policy at {MANAGED_CONFIG_PATH}: {exc} {hint}" ) raise ManagedPolicyInstallDenied(message) from exc installed = load_managed_state(workspace) @@ -975,13 +987,16 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag def _machine_policy_supported() -> bool: - """Whether ug installs a machine policy on this OS at all (Linux and macOS).""" - return current_os() in (OS.LINUX, OS.MACOS) + """Whether ug installs a machine policy on this OS at all (Linux, macOS, and Windows). + + On Windows the path is the protected ProgramData file, never the POSIX one. + """ + return current_os() in (OS.LINUX, OS.MACOS, OS.WINDOWS) def _machine_policy_installable() -> bool: - """Whether this machine can hold the installed workspace policy (Linux or macOS, not opted out - with ``UG_SKIP_OS_SETTINGS``).""" + """Whether this machine can hold the installed workspace policy (Linux, macOS, or Windows, not + opted out with ``UG_SKIP_OS_SETTINGS``).""" return not managed_settings_skipped() and _machine_policy_supported() diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index 5e355649c..78c2f514b 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -42,7 +42,6 @@ MANAGED_BACKUP_DIR = APP_DIR / "managed-backups" MANAGED_BACKUP_MANIFEST_PATH = MANAGED_BACKUP_DIR / "manifest.json" MANAGED_FINGERPRINT_VERSION = 1 -UNITY_GATEWAY_MANAGED_SETTINGS_PATH = Path("/etc/unitygateway/managed-settings.json") _MISSING = object() _managed_write_batch: tuple[str, ...] = () _managed_write_notice_shown = False @@ -68,36 +67,50 @@ class OS(Enum): MANAGED_FILES_SUPPORTED_OSES = (OS.LINUX, OS.MACOS, OS.WINDOWS) + +def current_os() -> OS: + """Map `sys.platform` onto :class:`OS` (lowercased, so a mixed-case value can't slip through).""" + platform = sys.platform.lower() + if platform.startswith("linux"): + return OS.LINUX + if platform == "darwin": + return OS.MACOS + if platform.startswith("win"): + return OS.WINDOWS + return OS.OTHER + + +_POSIX_UNITY_GATEWAY_MANAGED_SETTINGS_PATH = Path("/etc/unitygateway/managed-settings.json") + + +def unity_gateway_managed_settings_path() -> Path: + """The machine-wide workspace policy: root-owned on Linux/macOS, administrator-only under + ProgramData on Windows (readable by every user, like the other machine-wide agent files).""" + if current_os() is OS.WINDOWS: + return system_cross_os.windows_program_data_dir() / "UnityGateway" / "managed-settings.json" + return _POSIX_UNITY_GATEWAY_MANAGED_SETTINGS_PATH + + +UNITY_GATEWAY_MANAGED_SETTINGS_PATH = unity_gateway_managed_settings_path() + _SUDO_REPLACE_TARGETS = { OS.LINUX: frozenset( { Path("/etc/claude-code/managed-settings.json"), Path("/etc/codex/managed_config.toml"), - UNITY_GATEWAY_MANAGED_SETTINGS_PATH, + _POSIX_UNITY_GATEWAY_MANAGED_SETTINGS_PATH, } ), OS.MACOS: frozenset( { Path("/Library/Application Support/ClaudeCode/managed-settings.json"), Path("/etc/codex/managed_config.toml"), - UNITY_GATEWAY_MANAGED_SETTINGS_PATH, + _POSIX_UNITY_GATEWAY_MANAGED_SETTINGS_PATH, } ), } -def current_os() -> OS: - """Map `sys.platform` onto :class:`OS` (lowercased, so a mixed-case value can't slip through).""" - platform = sys.platform.lower() - if platform.startswith("linux"): - return OS.LINUX - if platform == "darwin": - return OS.MACOS - if platform.startswith("win"): - return OS.WINDOWS - return OS.OTHER - - def set_config_scope(scope: str) -> None: global _config_scope if scope not in CONFIG_SCOPES: @@ -1215,6 +1228,23 @@ def _sudo_replace_command(mode: str, *args: str, non_interactive: bool = False) return _sudo_command(*command) +def _write_windows_workspace_policy(path: Path, desired_text: str | None) -> None: + """Install or remove the policy from an elevated process.""" + try: + if desired_text is None: + _privileged_remove(path) + else: + _privileged_replace(path, desired_text) + except PermissionError as exc: + raise ManagedFileWriteUnavailable( + f"Could not update workspace policy at {path}: {exc}" + ) from exc + if read_managed_file(path) != desired_text: + raise ManagedFileWriteUnavailable( + f"Workspace policy at {path} changed during installation; run `ug configure` again." + ) + + def update_workspace_policy( path: Path, desired_text: str | None, @@ -1222,30 +1252,45 @@ def update_workspace_policy( interactive: bool = False, allow_sudo: bool = False, ) -> None: - """Install or remove changed policy; unprivileged launches never invoke sudo.""" + """Install or remove changed policy; unprivileged launches never invoke sudo. + + Windows has no sudo: only an already-elevated process writes, whether it is a launch or an + explicit configure, like root on Linux and macOS. + """ if path != UNITY_GATEWAY_MANAGED_SETTINGS_PATH: raise RuntimeError(f"Refusing unexpected workspace-policy target: {path}") - if current_os() not in (OS.LINUX, OS.MACOS): - raise ManagedFileWriteUnavailable( - "Machine-wide workspace policy is supported only on Linux and macOS." - ) if managed_settings_skipped(): raise ManagedFileWriteUnavailable("Machine-wide workspace policy is skipped for this run.") - _validate_sudo_replace_target(path) + windows = current_os() is OS.WINDOWS + if not windows and current_os() not in (OS.LINUX, OS.MACOS): + raise ManagedFileWriteUnavailable( + "Machine-wide workspace policy is supported only on Linux, macOS, and Windows." + ) + if not windows: + _validate_sudo_replace_target(path) current_text = read_managed_file(path) if current_text == desired_text: return # Checked before the dry-run preview, so a dry run never shows a write the real run refuses. - if not allow_sudo and os.geteuid() != 0: + if windows: + if not system_cross_os.is_admin(): + raise ManagedFileWriteUnavailable( + _windows_admin_required_message("Unity Gateway workspace", path, "ug configure") + ) + elif not allow_sudo and os.geteuid() != 0: raise ManagedFileWriteUnavailable( "Workspace policy updates require an administrator or root provisioning run." ) if is_dry_run(): + via = "as administrator" if windows else "via sudo" console.print( - f"\n[bold]\\[dry run] {path} (via sudo)[/bold]\n" + f"\n[bold]\\[dry run] {path} ({via})[/bold]\n" f"{desired_text if desired_text is not None else '(remove)'}" ) return + if windows: + _write_windows_workspace_policy(path, desired_text) + return running_as_root = os.geteuid() == 0 can_prompt = interactive and managed_writes_allowed() and not running_as_root # Run directly as root (no sudo), the script still checks the temp source's owner against @@ -1441,8 +1486,16 @@ def _privileged_remove(path: Path) -> None: path.unlink(missing_ok=True) +def _windows_managed_targets() -> tuple[Path, ...]: + return ( + windows_claude_managed_settings_path(), + windows_codex_managed_settings_path(), + UNITY_GATEWAY_MANAGED_SETTINGS_PATH, + ) + + def _validate_windows_target(path: Path) -> None: - if path not in (windows_claude_managed_settings_path(), windows_codex_managed_settings_path()): + if path not in _windows_managed_targets(): raise RuntimeError(f"Refusing unexpected managed-settings target: {path}") if path.is_symlink(): raise RuntimeError(f"Refusing to replace symlinked managed settings: {path}") @@ -1451,6 +1504,9 @@ def _validate_windows_target(path: Path) -> None: for folder in (path.parent, path.parent.parent): if folder.is_symlink() or folder.is_junction(): raise RuntimeError(f"Refusing to write managed settings through link {folder}") + elif path == UNITY_GATEWAY_MANAGED_SETTINGS_PATH: + if path.parent.is_symlink() or path.parent.is_junction(): + raise RuntimeError(f"Refusing to write managed settings through link {path.parent}") # Well-known SIDs, because Windows translates the group names. @@ -1459,36 +1515,43 @@ def _validate_windows_target(path: Path) -> None: _WINDOWS_USERS_SID = "*S-1-5-32-545" +def _run_windows_icacls(folder: Path, *change: str) -> None: + """Apply one ``icacls`` change to a folder itself (``/L``), failing the write if it can't.""" + icacls = str(system_cross_os.windows_system32_dir() / "icacls.exe") + result = subprocess_cross_os.run( + [icacls, str(folder), *change, "/L"], capture_output=True, text=True, check=False + ) + if result.returncode: + output = f"{result.stdout}{result.stderr}".strip() + raise PermissionError(f"could not secure {folder}: {output}") + + +def _secure_windows_leaf_folder(folder: Path) -> None: + """Give a folder to Administrators and let standard users only read it, like the root-owned + 0755 folders on Linux and macOS.""" + # Set the owner before resetting: the inherited CREATOR OWNER entry maps to the current owner. + _run_windows_icacls(folder, "/setowner", _WINDOWS_ADMINISTRATORS_SID) + _run_windows_icacls( + folder, + "/inheritance:r", + "/grant:r", + f"{_WINDOWS_SYSTEM_SID}:(OI)(CI)F", + f"{_WINDOWS_ADMINISTRATORS_SID}:(OI)(CI)F", + f"{_WINDOWS_USERS_SID}:(OI)(CI)RX", + ) + + def _secure_windows_codex_folders(path: Path) -> None: """Give the ``OpenAI`` and ``Codex`` folders to Administrators and let standard users only read - ``Codex``, like the root-owned 0755 folders on Linux and macOS. A standard user may have created - them first and would otherwise keep control. + ``Codex``. A standard user may have created them first and would otherwise keep control. Only the folders change, never their contents, so a planted hard link can't redirect the change onto another file. ``/L`` changes a link itself rather than its target, and the caller checks for links again afterwards.""" - icacls = str(system_cross_os.windows_system32_dir() / "icacls.exe") codex, openai = path.parent, path.parent.parent - # Set the owner before resetting: the inherited CREATOR OWNER entry maps to the current owner. - for folder, *change in ( - (openai, "/setowner", _WINDOWS_ADMINISTRATORS_SID), - (openai, "/reset"), - (codex, "/setowner", _WINDOWS_ADMINISTRATORS_SID), - ( - codex, - "/inheritance:r", - "/grant:r", - f"{_WINDOWS_SYSTEM_SID}:(OI)(CI)F", - f"{_WINDOWS_ADMINISTRATORS_SID}:(OI)(CI)F", - f"{_WINDOWS_USERS_SID}:(OI)(CI)RX", - ), - ): - result = subprocess_cross_os.run( - [icacls, str(folder), *change, "/L"], capture_output=True, text=True, check=False - ) - if result.returncode: - output = f"{result.stdout}{result.stderr}".strip() - raise PermissionError(f"could not secure {folder}: {output}") + _run_windows_icacls(openai, "/setowner", _WINDOWS_ADMINISTRATORS_SID) + _run_windows_icacls(openai, "/reset") + _secure_windows_leaf_folder(codex) def _windows_replace(path: Path, desired_text: str) -> None: @@ -1496,13 +1559,15 @@ def _windows_replace(path: Path, desired_text: str) -> None: The temp file is created beside the target so the rename stays on one volume and the new file inherits the folder's ACL, which lets standard users read but not write: Program Files comes - that way, and the Codex folders under ProgramData are secured first.""" + that way, and the Codex and Unity Gateway folders under ProgramData are secured first.""" _validate_windows_target(path) if not system_cross_os.is_admin(): raise PermissionError("administrator rights are required") path.parent.mkdir(parents=True, exist_ok=True) if path == windows_codex_managed_settings_path(): _secure_windows_codex_folders(path) + elif path == UNITY_GATEWAY_MANAGED_SETTINGS_PATH: + _secure_windows_leaf_folder(path.parent) _validate_windows_target(path) # a folder may have been created or swapped for a link meanwhile fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") try: diff --git a/tests/README.md b/tests/README.md index ed05edb9f..890611c4b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -758,7 +758,9 @@ desktop GUI, a real `launchctl` or `schtasks` run, or a login/reboot pass. `test_managed_config.py` and `test_managed_files.py` cover machine policy versus user-owned `refresh_ts.json`, five-minute throttling, no privileged write for unchanged policy or -retrieval-only drift, configure-only sudo selection, deferred-update warning/fallback, matching +retrieval-only drift, configure-only sudo selection, Windows ProgramData install/replace/removal +with a stubbed `icacls` (elevated only, administrator hint otherwise, linked folders refused), +Windows launch/revert rules, per-user cache only for `UG_SKIP_OS_SETTINGS`, deferred-update warning/fallback, matching removal, workspace isolation, fingerprint invalidation, and dry-run behavior. Pending-change regressions cover one warning across expired checks, distinct-change warnings, retrieval/key-order stability, removal, and clearing after remote/MDM/configure reconciliation. diff --git a/tests/integration/utils/managed.py b/tests/integration/utils/managed.py index 442acf983..a2a450b34 100644 --- a/tests/integration/utils/managed.py +++ b/tests/integration/utils/managed.py @@ -41,12 +41,16 @@ def is_managed_config_control_plane_cache(home: Path, path: Path) -> bool: """Whether ``path`` is ug's workspace-policy state, not agent-owned configuration. That is the installed policy and its refresh stamps, or, where the machine cannot hold the - installed policy (Windows), the per-user cache of the fetched policy. + installed policy (UG_SKIP_OS_SETTINGS), the per-user cache of the fetched policy. The installed + policy lives under ProgramData on Windows, read from the environment like the other Windows + policy paths. """ + program_data = os.environ.get("PROGRAMDATA", "").strip() or r"C:\ProgramData" return path in ( home / ".ucode" / "refresh_ts.json", home / ".ucode" / "managed-config.json", Path("/etc/unitygateway/managed-settings.json"), + Path(program_data) / "UnityGateway" / "managed-settings.json", ) diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 37861b039..006ea6093 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -133,6 +133,7 @@ def test_windows_policy_paths_match_pinned_agent_locations(): ) assert paths == ( + Path("C:/ProgramData/UnityGateway/managed-settings.json"), Path("C:/ProgramData/OpenAI/Codex/requirements.toml"), Path("C:/ProgramData/OpenAI/Codex/config.toml"), Path("C:/Program Files/ClaudeCode/managed-settings.json"), diff --git a/tests/test_managed_config.py b/tests/test_managed_config.py index c2a8fb816..2244f2b95 100644 --- a/tests/test_managed_config.py +++ b/tests/test_managed_config.py @@ -5,6 +5,7 @@ import json import os import stat +import subprocess import sys from datetime import UTC, datetime, timedelta @@ -13,6 +14,7 @@ import ucode.config_io as config_io_mod import ucode.databricks as db_mod import ucode.managed_config as mc_mod +import ucode.managed_files as mf_mod from ucode.managed_config import ( get_managed_config, load_managed_configuration, @@ -1355,8 +1357,157 @@ def test_fetch_failure_preserves_pending_hash_and_check_time(self, monkeypatch): assert mc_mod.REFRESH_TS_PATH.read_bytes() == original +_REAL_UPDATE_WORKSPACE_POLICY = mf_mod.update_workspace_policy +_REAL_WINDOWS_REPLACE = mf_mod._windows_replace + + +class TestWindowsInstalledPolicy: + """Windows holds the installed policy under ProgramData and follows the Linux/macOS rules.""" + + @pytest.fixture(autouse=True) + def windows(self, tmp_path, monkeypatch): + path = tmp_path / "ProgramData" / "UnityGateway" / "managed-settings.json" + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW) + monkeypatch.setattr(mc_mod, "get_databricks_token", lambda ws, profile: "tok") + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (RAW_MANIFEST, None)) + for module in (mc_mod, mf_mod): + monkeypatch.setattr(module, "current_os", lambda: mf_mod.OS.WINDOWS) + monkeypatch.setattr(mc_mod, "MANAGED_CONFIG_PATH", path) + monkeypatch.setattr(mf_mod, "UNITY_GATEWAY_MANAGED_SETTINGS_PATH", path) + monkeypatch.setattr(mc_mod, "update_workspace_policy", _REAL_UPDATE_WORKSPACE_POLICY) + monkeypatch.setattr(mf_mod, "_windows_replace", _REAL_WINDOWS_REPLACE) + # Never run the real icacls; record the folder changes instead. + self.icacls = [] + + def run(args, **kwargs): + self.icacls.append(args) + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(mf_mod.subprocess_cross_os, "run", run) + self.path = path + self.set_admin(monkeypatch, True) + + def set_admin(self, monkeypatch, admin): + monkeypatch.setattr(mf_mod.system_cross_os, "is_admin", lambda: admin) + monkeypatch.setattr(mc_mod, "is_admin", lambda: admin) + + def test_policy_path_is_under_program_data(self, tmp_path, monkeypatch): + monkeypatch.setenv("ProgramData", str(tmp_path / "PD")) + monkeypatch.setattr(mf_mod, "current_os", lambda: mf_mod.OS.WINDOWS) + + assert mf_mod.unity_gateway_managed_settings_path() == ( + tmp_path / "PD" / "UnityGateway" / "managed-settings.json" + ) + + def test_elevated_configure_installs_replaces_and_removes(self, monkeypatch): + refresh_managed_config(_state(), force_refresh=True) + assert json.loads(self.path.read_text(encoding="utf-8"))["workspace"] == WORKSPACE + assert [entry.name for entry in self.path.parent.iterdir()] == [self.path.name] + assert self.icacls + + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (changed, None)) + refresh_managed_config(_state(), force_refresh=True) + assert load_managed_configuration(WORKSPACE) == changed + + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (None, None)) + refresh_managed_config(_state(), force_refresh=True) + assert not self.path.exists() + + def test_non_elevated_configure_requires_an_administrator(self, monkeypatch): + self.set_admin(monkeypatch, False) + + with pytest.raises(mc_mod.ManagedPolicyInstallDenied, match="elevated terminal"): + refresh_managed_config(_state(), force_refresh=True) + + assert not self.path.exists() + assert not self.icacls + + def test_non_admin_launch_with_nothing_installed_applies_fetched_policy(self, monkeypatch): + self.set_admin(monkeypatch, False) + + result = refresh_managed_config(_state()) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert not self.path.exists() + assert not mc_mod.USER_MANAGED_CONFIG_PATH.exists() + assert not self.icacls + + def test_non_admin_launch_never_replaces_the_installed_policy(self, monkeypatch): + refresh_managed_config(_state(), force_refresh=True) + original = self.path.read_bytes() + self.icacls.clear() + self.set_admin(monkeypatch, False) + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW + timedelta(minutes=6)) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (changed, None)) + + result = refresh_managed_config(_state()) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert self.path.read_bytes() == original + assert not self.icacls + + def test_unchanged_launch_does_not_write(self, monkeypatch): + refresh_managed_config(_state(), force_refresh=True) + self.icacls.clear() + original = self.path.stat().st_mtime_ns + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW + timedelta(minutes=6)) + + refresh_managed_config(_state()) + + assert self.path.stat().st_mtime_ns == original + assert not self.icacls + + def test_foreign_workspace_is_refused(self): + refresh_managed_config(_state(), force_refresh=True) + foreign = _state(workspace="https://other.example.com") + + with pytest.raises(RuntimeError, match="Launch refused"): + refresh_managed_config(foreign) + + def test_only_the_program_data_file_is_read(self, tmp_path): + # A POSIX-style path under a drive root is never consulted on Windows. + planted = tmp_path / "etc" / "unitygateway" / "managed-settings.json" + planted.parent.mkdir(parents=True) + planted.write_text( + json.dumps({"workspace": "https://planted.example.com", "config": RAW_MANIFEST}), + encoding="utf-8", + ) + + assert mc_mod.managed_state_workspace() is None + assert load_managed_configuration("https://planted.example.com") is None + refresh_managed_config(_state(), force_refresh=True) + assert mc_mod.managed_state_workspace() == WORKSPACE + + def test_skip_mode_uses_the_per_user_cache(self, monkeypatch): + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + + result = refresh_managed_config(_state(), force_refresh=True) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert mc_mod.USER_MANAGED_CONFIG_PATH.exists() + assert not self.path.exists() + + def test_elevated_revert_removes_the_policy(self): + refresh_managed_config(_state(), force_refresh=True) + + assert mc_mod.revert_machine_policy() == "removed" + assert not self.path.exists() + + def test_non_elevated_revert_warns_and_keeps_the_policy(self, monkeypatch): + refresh_managed_config(_state(), force_refresh=True) + self.set_admin(monkeypatch, False) + warnings = [] + monkeypatch.setattr(mc_mod, "print_warning", warnings.append) + + assert mc_mod.revert_machine_policy() == "failed" + assert self.path.exists() + assert "elevated terminal" in warnings[0] + + class TestPolicyWithoutInstallation: - """Windows and UG_SKIP_OS_SETTINGS can't hold an installed policy.""" + """UG_SKIP_OS_SETTINGS can't hold an installed policy.""" @pytest.fixture(autouse=True) def _stub_clock_and_fetch(self, monkeypatch): @@ -1364,11 +1515,11 @@ def _stub_clock_and_fetch(self, monkeypatch): monkeypatch.setattr(mc_mod, "get_databricks_token", lambda ws, profile: "tok") monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (RAW_MANIFEST, None)) - @pytest.fixture(params=["skip_os_settings", "windows"]) + @pytest.fixture(params=["linux", "windows"]) def not_installable(self, request, monkeypatch): - if request.param == "skip_os_settings": - monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") - else: + # The opt-out applies on every OS, Windows included. + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + if request.param == "windows": monkeypatch.setattr(mc_mod, "current_os", lambda: mc_mod.OS.WINDOWS) monkeypatch.setattr( mc_mod, @@ -1418,17 +1569,6 @@ def test_per_user_cache_is_private(self, not_installable): assert stat.S_IMODE(mc_mod.USER_MANAGED_CONFIG_PATH.stat().st_mode) == 0o600 - def test_windows_ignores_a_planted_posix_policy_file(self, monkeypatch): - """On Windows the POSIX path sits under a drive root a standard user may create.""" - save_managed_state("https://planted.example.com", {"default_agent": "CODING_AGENT_CODEX"}) - monkeypatch.setattr(mc_mod, "current_os", lambda: mc_mod.OS.WINDOWS) - - result = refresh_managed_config(_state()) - - assert result.manifest == normalize_managed_config(RAW_MANIFEST) - assert mc_mod.managed_state_workspace() is None - assert load_managed_configuration("https://planted.example.com") is None - def test_export_finds_the_per_user_cache_without_a_saved_workspace(self, not_installable): refresh_managed_config(_state()) @@ -1693,14 +1833,10 @@ def test_revert_without_a_policy_writes_nothing(self, monkeypatch): assert mc_mod.revert_machine_policy() == "unchanged" - @pytest.mark.parametrize("mode", ["skip_os_settings", "windows"]) - def test_revert_leaves_the_policy_where_ug_does_not_manage_it(self, monkeypatch, mode): + def test_revert_leaves_the_policy_where_ug_does_not_manage_it(self, monkeypatch): mc_mod.MANAGED_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) mc_mod.MANAGED_CONFIG_PATH.write_text("{}", encoding="utf-8") - if mode == "skip_os_settings": - monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") - else: - monkeypatch.setattr(mc_mod, "current_os", lambda: mc_mod.OS.WINDOWS) + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") assert mc_mod.revert_machine_policy() == "unchanged" assert mc_mod.MANAGED_CONFIG_PATH.exists() diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index 4783af283..f737be86e 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -788,6 +788,136 @@ def test_policy_symlink_is_rejected_before_sudo(self, policy_path, tmp_path, mon assert target.read_text() == "user settings" +class TestWindowsWorkspacePolicyWriter: + """Windows holds the workspace policy under ProgramData, written only by an elevated process.""" + + @pytest.fixture + def policy_path(self, tmp_path, monkeypatch): + monkeypatch.setenv("ProgramData", str(tmp_path / "ProgramData")) + monkeypatch.setenv("SystemRoot", str(tmp_path / "Windows")) + monkeypatch.setattr(managed_files, "current_os", lambda: managed_files.OS.WINDOWS) + monkeypatch.setattr(managed_files, "_windows_replace", _REAL_WINDOWS_REPLACE) + path = managed_files.unity_gateway_managed_settings_path() + monkeypatch.setattr(managed_files, "UNITY_GATEWAY_MANAGED_SETTINGS_PATH", path) + monkeypatch.setattr(managed_files.system_cross_os, "is_admin", lambda: True) + self.calls = [] + + def run(args, **kwargs): + self.calls.append(args) + return subprocess.CompletedProcess(args, self.returncode, "Access is denied.", "") + + self.returncode = 0 + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", run) + return path + + def test_path_is_under_program_data(self, policy_path, tmp_path): + assert policy_path == tmp_path / "ProgramData" / "UnityGateway" / "managed-settings.json" + + def test_posix_path_is_unchanged_off_windows(self, monkeypatch): + monkeypatch.setattr(managed_files, "current_os", lambda: managed_files.OS.LINUX) + + assert managed_files.unity_gateway_managed_settings_path() == Path( + "/etc/unitygateway/managed-settings.json" + ) + + def test_elevated_run_installs_replaces_and_removes(self, policy_path, tmp_path): + managed_files.update_workspace_policy(policy_path, "one") + assert policy_path.read_text(encoding="utf-8") == "one" + managed_files.update_workspace_policy(policy_path, "two", allow_sudo=True) + assert policy_path.read_text(encoding="utf-8") == "two" + assert [entry.name for entry in policy_path.parent.iterdir()] == [policy_path.name] + + managed_files.update_workspace_policy(policy_path, None, allow_sudo=True) + assert not policy_path.exists() + + def test_folder_is_given_to_administrators_with_read_for_users(self, policy_path, tmp_path): + managed_files.update_workspace_policy(policy_path, "one") + + icacls = str(tmp_path / "Windows" / "System32" / "icacls.exe") + folder = str(policy_path.parent) + assert self.calls == [ + [icacls, folder, "/setowner", "*S-1-5-32-544", "/L"], + [ + icacls, + folder, + "/inheritance:r", + "/grant:r", + "*S-1-5-18:(OI)(CI)F", + "*S-1-5-32-544:(OI)(CI)F", + "*S-1-5-32-545:(OI)(CI)RX", + "/L", + ], + ] + + def test_unsecured_folder_blocks_the_write(self, policy_path): + self.returncode = 5 + + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="could not secure"): + managed_files.update_workspace_policy(policy_path, "one") + + assert not policy_path.exists() + + @pytest.mark.parametrize("allow_sudo", [False, True]) + @pytest.mark.parametrize("desired_text", ["new", None]) + def test_standard_user_gets_the_admin_hint( + self, policy_path, monkeypatch, allow_sudo, desired_text + ): + policy_path.parent.mkdir(parents=True) + policy_path.write_text("old", encoding="utf-8") + monkeypatch.setattr(managed_files.system_cross_os, "is_admin", lambda: False) + + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="administrator rights"): + managed_files.update_workspace_policy( + policy_path, desired_text, interactive=True, allow_sudo=allow_sudo + ) + + assert policy_path.read_text(encoding="utf-8") == "old" + assert not self.calls + + def test_elevated_launch_writes_like_root(self, policy_path): + managed_files.update_workspace_policy(policy_path, "new", allow_sudo=False) + + assert policy_path.read_text(encoding="utf-8") == "new" + + def test_unchanged_policy_needs_no_elevation(self, policy_path, monkeypatch): + policy_path.parent.mkdir(parents=True) + policy_path.write_text("same", encoding="utf-8") + monkeypatch.setattr(managed_files.system_cross_os, "is_admin", lambda: False) + + managed_files.update_workspace_policy(policy_path, "same") + + def test_skip_mode_is_rejected(self, policy_path, monkeypatch): + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="skipped"): + managed_files.update_workspace_policy(policy_path, "new") + + assert not policy_path.exists() + + def test_dry_run_writes_nothing(self, policy_path): + config_io.set_dry_run(True) + + managed_files.update_workspace_policy(policy_path, "new") + + assert not policy_path.exists() + assert not self.calls + + def test_refuses_an_unexpected_target(self, policy_path, tmp_path): + with pytest.raises(RuntimeError, match="unexpected workspace-policy target"): + managed_files.update_workspace_policy(tmp_path / "elsewhere.json", "new") + + def test_refuses_a_linked_folder(self, policy_path, tmp_path): + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + policy_path.parent.parent.mkdir() + policy_path.parent.symlink_to(elsewhere, target_is_directory=True) + + with pytest.raises(RuntimeError, match="through link"): + managed_files.update_workspace_policy(policy_path, "new") + + assert list(elsewhere.iterdir()) == [] + + class TestWorkspacePolicyAsRoot: def test_direct_root_run_points_sudo_uid_at_root(self, tmp_path, monkeypatch): """Under `sudo ug configure` SUDO_UID names the caller, but root owns the temp source."""