Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions docs/managed-workspace-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions scripts/run_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions specs/features/machine-managed-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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?
Expand Down
37 changes: 26 additions & 11 deletions src/ucode/managed_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."
)
Comment on lines +939 to +943

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
will this break current non-admin windows users? A non-elevated ug configure used to succeed via the per-user cache; now it raises ManagedPolicyInstallDenied. Once installable, load_managed_state also never reads the existing per-user cache. can we fall back to the per-user cache when not elevated?

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)
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
what happens if a standard user creates ProgramData\UnityGateway\managed-settings.json before any admin install? ProgramData lets users create subfolders, and _installed_config trusts the file without checking owner or ACL. On a shared machine, user A can plant {workspace: <B's workspace>, config: <hooks/MCP>}. When B launches, the non-admin write fails and the code continues with the installed policy, so A's hooks run as B. can we check that Administrators or SYSTEM own the file and folder before trusting it?



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()


Expand Down
Loading
Loading