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
52 changes: 52 additions & 0 deletions docs/managed-workspace-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Managed workspace policy

`ug configure` reads the workspace's published `CodingAgentConfig` through
`GET /api/ai-gateway/v2/coding-agent-configs` and installs it machine-wide at
`/etc/unitygateway/managed-settings.json` on Linux and macOS.

The installed file contains only:

```json
{
"workspace": "https://your-workspace",
"config": {}
}
```

`config` is the raw remote configuration, not normalized agent settings. The empty object above
only illustrates the shape: a workspace without a published policy does not leave a policy file.
The directory is root-owned `0755` and the file is root-owned `0644`, so ordinary users can read it
without sudo. Authentication tokens and profiles stay in user-owned state.

**Never put secrets in managed policy headers.** The policy is readable by every local user, and
ug does not redact raw fields. Do not put passwords, bearer tokens, API keys, or session cookies in
`http_headers` or anywhere else in the configuration. Routing headers such as a workspace ID are
appropriate.

## Configure

- `ug configure` forces a fresh read of the workspace policy, so it never installs a stale one.
- Changed policy is written with sudo from a terminal. Where sudo cannot prompt (for example in a
script), ug uses non-interactive sudo and fails if a password would be needed.
- Root-run configure installs directly, without sudo.
- Unchanged policy writes nothing. A re-read that differs only in `retrieved_time` is not a change.
- A workspace that publishes no policy, or has the feature disabled, removes the installed file.
Configure decides the machine's workspace, so this also removes a policy installed for a different
workspace.
- If the installation or removal fails, configure stops with an error that says the 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.
- Windows has no machine-wide policy file. `UG_SKIP_OS_SETTINGS=1` and `ug configure --user` also
skip it, so no file is written.

Launches never install or remove the machine-wide policy. Per-user caching of the fetched policy
(`~/.ucode/managed-config.json`) is unchanged by this write path.

## Limits

- The policy describes one workspace for the whole machine. Configuring another workspace replaces it
for all users.
- `ug revert` removes the policy, with sudo. Without sudo it warns and keeps the file.
- Installation needs sudo on Linux and macOS. On MDM machines, ask IT to run the provisioning step
rather than attempting administrator-only configure yourself.
- Live sudo and ownership checks need a disposable clean machine, not a developer workstation.
2 changes: 2 additions & 0 deletions scripts/run_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ def managed_policy_paths(
)
paths.append(Path(program_files) / "ClaudeCode" / "managed-settings.json")
elif platform_name == "posix":
# Unity Gateway's installed workspace policy affects every agent.
paths.append(Path("/etc/unitygateway/managed-settings.json"))
if "codex" in selected:
paths.extend(
[Path("/etc/codex/managed_config.toml"), Path("/etc/codex/requirements.toml")]
Expand Down
3 changes: 3 additions & 0 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
load_managed_state,
normalize_managed_config,
refresh_managed_config,
revert_machine_policy,
)
from ucode.managed_files import managed_write_session, set_config_scope
from ucode.managed_resolve import (
Expand Down Expand Up @@ -1462,6 +1463,7 @@ def revert() -> int:
print_warning(f"Could not remove leftover Copilot session files: {exc}")
claude_managed_result = claude_agent.revert_managed_settings()
codex_managed_result = codex_agent.revert_managed_config()
workspace_policy_result = revert_machine_policy()

results: dict[str, bool] = {
tool: restore_file(
Expand All @@ -1484,6 +1486,7 @@ def revert() -> int:
print_kv("Claude Code OS-managed settings", claude_managed_result)
print_kv("Claude Desktop profile", desktop_result)
print_kv("Codex OS-managed settings", codex_managed_result)
print_kv("Unity Gateway workspace policy", workspace_policy_result)
print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged")
for client, spec in MCP_CLIENTS.items():
print_kv(
Expand Down
104 changes: 101 additions & 3 deletions src/ucode/managed_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
- re-reading it on each launch (reusing a read younger than :data:`MANAGED_CONFIG_TTL` rather than
re-fetching), falling back to the persisted copy when the read fails.
There is deliberately one file: the workspace is the source of truth, so the pulled copy lives in
``managed-config.json`` and a launch re-reads it from there.
An explicit ``ug configure`` also installs the workspace policy machine-wide at
``/etc/unitygateway/managed-settings.json`` (Linux and macOS) through
:func:`ucode.managed_files.update_workspace_policy`. Launches never write that file; they read and
refresh only the per-user cache above.
Comment on lines +14 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

maintainability · medium
is this file read anywhere? nothing in src/ reads /etc/unitygateway/managed-settings.json, so configure now asks for sudo (and can hard fail) for a file nothing uses. can we hold this back until a reader lands, or put it behind a gate?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is intentional. The followup consumes the new file: #1260

:func:`refresh_managed_config` is the launch path's entry point. It is called before model discovery,
because the manifest decides whether that discovery is needed at all; the launch path then hands the
Expand All @@ -37,10 +39,19 @@
fetch_model_recommendation,
get_databricks_token,
)
from ucode.managed_files import (
OS,
UNITY_GATEWAY_MANAGED_SETTINGS_PATH,
current_os,
managed_settings_skipped,
update_workspace_policy,
)
from ucode.time_utils import parse_update_time
from ucode.ui import console, print_warning
from ucode.ui import console, normalize_workspace_url, print_warning

MANAGED_CONFIG_PATH = config_io.APP_DIR / "managed-config.json"
# The machine-wide workspace policy that explicit ``ug configure`` installs (Linux and macOS only).
MACHINE_POLICY_PATH = UNITY_GATEWAY_MANAGED_SETTINGS_PATH

# Shown to a developer when their workspace has no admin-defined managed config yet — the normal
# case, not an error. Kept here so the CLI (which surfaces it) uses one consistent message.
Expand Down Expand Up @@ -378,6 +389,10 @@ class FetchedManagedConfig(NamedTuple):
reason: str | None


class ManagedPolicyInstallDenied(RuntimeError):
"""``ug configure`` could not install, replace, or remove the machine-wide workspace policy."""


class ManagedConfigResult(NamedTuple):
"""The launch-path refresh outcome: the ``manifest`` to apply (None when absent or dropped) and
``feature_disabled``, True when the coding-agent-configs feature is off server-side."""
Expand Down Expand Up @@ -666,6 +681,86 @@ def save_managed_state(workspace: str, config: dict, *, outcome: str | None = No
_restrict_permissions(MANAGED_CONFIG_PATH)


def _machine_policy_installable() -> bool:
"""Whether this run can hold the machine-wide policy: Linux or macOS, not opted out with
``UG_SKIP_OS_SETTINGS`` or the ``user`` scope. Windows has no machine policy file."""
return not managed_settings_skipped() and current_os() in (OS.LINUX, OS.MACOS)


def _policy_content(config: dict) -> dict:
"""The config compared for changes; ``retrieved_time`` changes on every read, so it is ignored."""
return {key: value for key, value in config.items() if key != "retrieved_time"}


def _install_machine_policy(workspace: str, config: dict) -> None:
"""Make the machine policy match an explicit configure's result.
An empty ``config`` removes the installed policy, whichever workspace it was installed for: the
configured workspace now decides the machine's workspace. Otherwise the file is rewritten only
when its workspace or configuration differs. Raises RuntimeError when sudo is unavailable.
"""
if not config:
update_workspace_policy(MACHINE_POLICY_PATH, None, interactive=True, allow_sudo=True)
return
Comment on lines +702 to +704

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
what happens on a shared machine? if one user configures workspace B with no policy, workspace A's machine-wide policy gets deleted for everyone. a non-empty config overwrites it the same way. should we only remove a policy whose workspace matches ours?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We assume single user for ug today, so shared machine issues are not a concern. Since we are explicitly modifying the global files, it is expected that all users use the same workspace.

workspace = normalize_workspace_url(workspace)
installed = config_io.read_json_safe(MACHINE_POLICY_PATH)
installed_workspace = installed.get("workspace")
installed_config = installed.get("config")
if (
isinstance(installed_workspace, str)
and installed_workspace.strip()
and normalize_workspace_url(installed_workspace) == workspace
and isinstance(installed_config, dict)
and _policy_content(installed_config) == _policy_content(config)
):
return
payload = {"workspace": workspace, "config": config}
Comment thread
billiam-wang marked this conversation as resolved.
update_workspace_policy(
MACHINE_POLICY_PATH,
json.dumps(payload, indent=2) + "\n",
interactive=True,
allow_sudo=True,
)


def revert_machine_policy() -> str:
"""Remove the installed workspace policy for ``ug revert``: "removed", "unchanged", or "failed".
Nothing else writes this file, so revert deletes it outright. Without sudo it warns and keeps
the file, like the other machine-wide settings revert restores.
"""
if not _machine_policy_installable() or not MACHINE_POLICY_PATH.exists():
return "unchanged"
try:
update_workspace_policy(MACHINE_POLICY_PATH, None, interactive=True, allow_sudo=True)
except RuntimeError as exc:
print_warning(
f"Could not remove the workspace policy at {MACHINE_POLICY_PATH}: {exc} Rerun "
"`ug revert` from a terminal where you can use sudo."
)
return "failed"
return "removed"


def _sync_machine_policy(workspace: str, config: dict, *, force_refresh: bool) -> None:
Comment thread
billiam-wang marked this conversation as resolved.
"""Install or remove the machine policy for an explicit configure; launches never write it.
A failed install raises :class:`ManagedPolicyInstallDenied`, so configure reports it instead of
implying the machine is managed for this workspace.
"""
if not force_refresh or not _machine_policy_installable():
return
try:
_install_machine_policy(workspace, config)
except RuntimeError as exc:
message = (
f"Could not install the workspace policy at {MACHINE_POLICY_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."
)
raise ManagedPolicyInstallDenied(message) from exc

Copy link
Copy Markdown

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 ug users? on linux/macos every ug configure with a published config now needs sudo, and none of the callers (cli.py:830, 1049) catch this. CI, sudo -n runs, and users without sudo now abort halfway through configure, after the per-user cache is already saved and before any agent setup runs. can we downgrade this to print_warning and keep going?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Published config already needed sudo since it writes to the global file. This is not a new requirement.



def _restrict_permissions(path: Path) -> None:
"""Best-effort chmod 0600. No-op where unsupported (e.g. Windows), where the effective
read-only guarantee is left to a later change."""
Expand Down Expand Up @@ -783,6 +878,7 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag
if reason is not None:
if _is_feature_disabled(reason):
save_managed_state(workspace, {}, outcome=_OUTCOME_FEATURE_DISABLED)
_sync_machine_policy(workspace, {}, force_refresh=force_refresh)
return ManagedConfigResult(None, True)
fallback = _persisted_fallback(workspace, reason, refused=_is_permission_denied(reason))
return ManagedConfigResult(fallback, False)
Expand All @@ -791,9 +887,11 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag
# the file doubles as the fallback above, so a removed policy would otherwise come back
# into force after the next transient outage.
save_managed_state(workspace, {}, outcome=_OUTCOME_NONE)
_sync_machine_policy(workspace, {}, force_refresh=force_refresh)
return ManagedConfigResult(None, False)
# Persist the raw config verbatim; hand callers the normalized manifest they expect.
save_managed_state(workspace, raw, outcome=_OUTCOME_PUBLISHED)
_sync_machine_policy(workspace, raw, force_refresh=force_refresh)
return ManagedConfigResult(normalize_managed_config(raw), False)


Expand Down
90 changes: 87 additions & 3 deletions src/ucode/managed_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
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
Expand Down Expand Up @@ -72,12 +73,14 @@ class OS(Enum):
{
Path("/etc/claude-code/managed-settings.json"),
Path("/etc/codex/managed_config.toml"),
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,
}
),
}
Expand Down Expand Up @@ -1195,16 +1198,97 @@ def _merge_shared_list(doc: dict, path: list[str], admin_items: list, *, withdra
)


def _sudo_replace_command(mode: str, *args: str) -> list[str]:
return _sudo_command(
def _sudo_replace_command(mode: str, *args: str, non_interactive: bool = False) -> list[str]:
command = [
"/bin/sh",
"-c",
_SUDO_REPLACE_SCRIPT,
"ucode-managed-replace",
mode,
current_os().value,
*args,
)
]
if non_interactive:
if os.geteuid() == 0:
return command
return [_SUDO, "-n", *command]
return _sudo_command(*command)


def update_workspace_policy(
path: Path,
desired_text: str | None,
*,
interactive: bool = False,
allow_sudo: bool = False,
) -> None:
"""Install or remove changed policy; unprivileged launches never invoke sudo."""
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)
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:
raise ManagedFileWriteUnavailable(
"Workspace policy updates require an administrator or root provisioning run."
)
if is_dry_run():
console.print(
f"\n[bold]\\[dry run] {path} (via sudo)[/bold]\n"
f"{desired_text if desired_text is not None else '(remove)'}"
)
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
# SUDO_UID. Under `sudo ug configure` that variable names the invoking user while root owns the
# source, so point it at root for the direct run.
script_env = {**os.environ, "SUDO_UID": "0"} if running_as_root else None
try:
if desired_text is not None and can_prompt:
_sudo_replace(path, desired_text)
else:
# Raw bytes, so removal's byte-for-byte check matches a file with a BOM or CRLF endings.
with tempfile.NamedTemporaryFile(mode="wb", suffix=".json") as source:
source.write(
desired_text.encode("utf-8") if desired_text is not None else path.read_bytes()
)
source.flush()
subprocess_cross_os.run(
_sudo_replace_command(
"once" if desired_text is not None else "remove",
source.name,
str(path),
non_interactive=not can_prompt,
),
capture_output=True,
text=True,
check=True,
# A password prompt waits on the person typing; only unattended runs time out.
timeout=None if can_prompt else 30,
env=script_env,
)
except (OSError, subprocess.SubprocessError) as exc:
stderr = getattr(exc, "stderr", None)
if isinstance(stderr, bytes):
# TimeoutExpired carries bytes even when the run used text mode.
stderr = stderr.decode("utf-8", "replace")
detail = (stderr or "").strip()
raise ManagedFileWriteUnavailable(
f"Could not update workspace policy at {path}." + (f" {detail}" if detail else "")
) from exc
if read_managed_file(path) != desired_text:
raise ManagedFileWriteUnavailable(
f"Workspace policy at {path} changed during installation; run `ug configure` again."
)


def _encode_worker_arg(value: str) -> str:
Expand Down
Loading
Loading