Skip to content
Merged
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
34 changes: 20 additions & 14 deletions docs/os-managed-settings-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ The two stacked PRs make precedence handling deterministic:
managed-settings path.

After both PRs merge, interactive configuration reconciles the agent's OS-managed file by default.
Non-interactive and CI execution never elevates privileges and instead uses local settings when the
managed file is compatible.
Non-interactive execution uses local settings when the managed file is compatible. Both agents repair
conflicting managed settings using `sudo -n`, which requires existing authorization and never prompts
for a password. If that repair is denied, the agent does not launch.

## Configuration Files

Expand All @@ -28,10 +29,14 @@ configuration, except for Claude subscription relay.

## Interactive Detection

An invocation may modify OS-managed settings only when standard input is a TTY. Standard output does
not affect the decision, so piping logs does not disable an otherwise interactive configuration.
CI, pipes, cron jobs, and headless subprocesses normally have non-TTY standard input and therefore
remain local-only.
Standard input determines whether UG may request administrator permission interactively. Standard
output does not affect the decision. Without a TTY, either agent may repair an existing conflicting
file through one-shot `sudo -n` with stdin disconnected; absent or compatible files remain local-only.
A denied repair blocks launch.

`managed_writes_allowed` centralizes this policy: its default remains interactive-only, while the
shared reconciliation/writer permits an existing-file repair attempt. The writer derives sudo's
prompt mode and stdin handling itself; the predicate does not probe or grant authorization.

This is a new shared ucode distinction. The previous implementation inferred interactivity from
command shape in some flows and did not guard managed-file writes consistently.
Expand All @@ -46,12 +51,13 @@ command shape in some flows and did not guard managed-file writes consistently.
| Interactive | Already identical | Continue without a backup, write, or `sudo` invocation. |
| Non-interactive | Absent | Use the local ucode file. Do not create the managed file. |
| Non-interactive | Ucode-owned values absent or equal | Use the local ucode file. Do not modify the managed file. |
| Non-interactive | Ucode-owned value conflicts | Stop before launching because the higher-precedence value would override ucode. |
| Non-interactive | Ucode-owned value conflicts | Repair through `sudo -n`; stop before launching if authorization or verification fails. |
| Any | Invalid, unreadable, or symlinked | Stop without modifying the file because precedence cannot be established safely. |

`ucode configure`, first-time `ucode claude` or `ucode codex`, and later launches all use the same
agent-specific reconciliation path. A first-time launch from an interactive terminal can therefore
request administrator permission. A first-time non-interactive launch remains local-only.
request administrator permission. A first-time non-interactive launch uses local settings unless
the agent must repair an existing conflicting managed file.

For Claude, turning off UG tracing preserves the live OS-managed telemetry values unchanged,
including values UG wrote on an earlier run. The same applies when the workspace has no managed
Expand Down Expand Up @@ -99,8 +105,8 @@ errors:
- retries once only when device management restored the exact pre-write contents;
- preserves a concurrently changed policy instead of overwriting it.

The privilege boundary is interactive. Non-interactive paths do not invoke either normal `sudo` or
`sudo -n`.
Interactive updates may request administrator permission. Non-interactive conflict repairs use only
`sudo -n` with stdin disconnected.

Root access cannot sustainably override an actively enforced policy. If an MDM process immediately
restores the original file twice, ucode stops with an error instead of repeatedly fighting the
Expand Down Expand Up @@ -157,7 +163,7 @@ to unchanged launches.

The verification scopes distinguish:

- an interactively reconciled managed file;
- a managed file reconciled interactively or through an authorized non-prompting repair;
- a managed file verified as compatible with local settings;
- a managed file verified as compatible with Claude relay.

Expand Down Expand Up @@ -196,9 +202,9 @@ result. An identical file produces no elevation message.
Representative blockers are:

```text
Claude Code configuration cannot be applied non-interactively because OS-managed settings at
<path> override ucode values: env.ANTHROPIC_BASE_URL. Run `ucode configure --agent claude` from an
interactive terminal or contact your administrator.
Claude Code cannot start because ucode could not update <path>: sudo: a password is required.
Run the ucode command from an interactive terminal and approve the administrator prompt, or contact
your administrator.
```

```text
Expand Down
12 changes: 2 additions & 10 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -1694,15 +1694,8 @@ def _reconcile_managed_settings(
managed_before = copy.deepcopy(existing)
desired_settings = compose(existing)
_preserve_permission_denies(managed_before, desired_settings)
if not managed_writes_allowed():
conflicts = _managed_settings_conflicts(managed_before, desired_settings, owned_paths)
if conflicts:
raise RuntimeError(
"Claude Code configuration cannot be applied non-interactively because "
f"OS-managed settings at {path} override ucode values: {', '.join(conflicts)}. "
"Run `ucode configure --agent claude` from an interactive terminal or contact "
"your administrator."
)
conflicts = _managed_settings_conflicts(managed_before, desired_settings, owned_paths)
if not managed_writes_allowed() and not conflicts:
mark_managed_file_verified(state, "claude", path, scope="local-compatible")
return
try:
Expand All @@ -1715,7 +1708,6 @@ def _reconcile_managed_settings(
parser=_parse_managed_settings,
)
except ManagedFileWriteUnavailable:
conflicts = _managed_settings_conflicts(managed_before, desired_settings, owned_paths)
if conflicts:
raise
print_warning(
Expand Down
12 changes: 2 additions & 10 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,15 +606,8 @@ def _reconcile_managed_config(state: dict, compose: Callable[[dict], dict]) -> N
) from exc
managed_before = copy.deepcopy(existing)
desired_doc = compose(existing)
if not managed_writes_allowed():
conflicts = managed_file_conflicts(managed_before, desired_doc, MANAGED_KEYS)
if conflicts:
raise RuntimeError(
"Codex configuration cannot be applied non-interactively because OS-managed "
f"settings at {path} override ucode values: {', '.join(conflicts)}. Run `ucode "
"configure --agent codex` from an interactive terminal or contact your "
"administrator."
)
conflicts = managed_file_conflicts(managed_before, desired_doc, MANAGED_KEYS)
if not managed_writes_allowed() and not conflicts:
mark_managed_file_verified(state, "codex", path, scope="local-compatible")
return
try:
Expand All @@ -627,7 +620,6 @@ def _reconcile_managed_config(state: dict, compose: Callable[[dict], dict]) -> N
parser=_parse_managed_config,
)
except ManagedFileWriteUnavailable:
conflicts = managed_file_conflicts(managed_before, desired_doc, MANAGED_KEYS)
if conflicts:
raise
print_warning_err(
Expand Down
40 changes: 23 additions & 17 deletions src/ucode/managed_files.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Safely manage root-owned, highest-precedence agent settings files.

Interactive updates preserve unrelated policy, retain a private baseline for ``ucode revert``, and
verify the privileged atomic replacement. Non-interactive runs only check whether existing managed
values are compatible with ucode's local settings.
verify the privileged atomic replacement. Existing-file repairs may run without a terminal;
the shared writer uses sudo's non-prompting mode without consuming the caller's standard input.
"""

from __future__ import annotations
Expand Down Expand Up @@ -168,11 +168,16 @@ def managed_file_scope(state: dict, tool: str) -> str:
return scope if isinstance(scope, str) else "managed"


def managed_writes_allowed() -> bool:
"""Managed writes are interactive setup work; scripts and CI use local settings."""
def _sudo_may_prompt() -> bool:
"""A terminal is attached, so sudo can prompt and the persistent worker is usable."""
return sys.stdin.isatty()


def managed_writes_allowed(*, repair_existing: bool = False) -> bool:
"""Writes need either an interactive terminal or an existing file to repair in place."""
return _sudo_may_prompt() or repair_existing


@contextmanager
def managed_write_batch(displays: list[str]) -> Iterator[None]:
"""Group setup messaging for agents configured in one command."""
Expand Down Expand Up @@ -468,17 +473,17 @@ def reconcile_managed_file(
f"{display}: OS-managed settings aren't supported on this platform; skipped {path}."
)
return "unsupported"
if not managed_writes_allowed() and not is_dry_run():
raise RuntimeError(
f"Refusing to update {display} managed settings at {path} non-interactively. "
"Run the command from an interactive terminal."
)
if path.is_symlink():
raise RuntimeError(
f"Refusing to update {display} managed settings through symlink {path}. "
"Replace it with a regular file or contact your administrator."
)
current_text = read_managed_file(path)
if not managed_writes_allowed(repair_existing=current_text is not None) and not is_dry_run():
raise RuntimeError(
f"Refusing to update {display} managed settings at {path} non-interactively. "
"Run the command from an interactive terminal."
)
if current_text == desired_text:
return "unchanged"
if current_text is not None:
Expand All @@ -497,7 +502,8 @@ def reconcile_managed_file(

created = current_text is None
_ensure_backup(tool, path, current_text)
_print_managed_write_permission(display)
if _sudo_may_prompt():
_print_managed_write_permission(display)
if read_managed_file(path) != current_text:
raise RuntimeError(
f"{display} managed settings changed while ucode was preparing the update. "
Expand Down Expand Up @@ -1009,16 +1015,17 @@ def _sudo_remove(path: Path) -> None:

def _sudo_replace(path: Path, desired_text: str) -> None:
"""Atomically replace ``path`` while preserving metadata and file flags."""
if not managed_writes_allowed():
raise RuntimeError("Refusing to invoke sudo for managed settings non-interactively.")
_validate_sudo_replace_target(path)
if not managed_writes_allowed(repair_existing=path.is_file()):
raise RuntimeError("Refusing to create managed settings non-interactively.")
non_interactive = not _sudo_may_prompt()
with tempfile.NamedTemporaryFile(
mode="w", suffix=path.suffix or ".tmp", delete=False, encoding="utf-8"
) as tmp:
tmp.write(desired_text)
tmp_path = tmp.name
try:
if _managed_write_session_depth:
if _managed_write_session_depth and not non_interactive:
_session_worker().replace(path, tmp_path)
else:
subprocess_cross_os.run(
Expand All @@ -1027,6 +1034,7 @@ def _sudo_replace(path: Path, desired_text: str) -> None:
tmp_path,
str(path),
),
stdin=subprocess.DEVNULL if non_interactive else None,
capture_output=True,
text=True,
check=True,
Expand Down Expand Up @@ -1105,7 +1113,5 @@ def _sudo_failure_message(path: Path, display: str, exc: subprocess.CalledProces


def _sudo_command(*args: str) -> list[str]:
"""Build a sudo command only for an explicitly interactive managed-file operation."""
if not managed_writes_allowed():
raise RuntimeError("Refusing to invoke sudo for managed settings non-interactively.")
return [_SUDO, *args]
"""Never prompt for privileged commands when no terminal is available."""
return [_SUDO, *([] if _sudo_may_prompt() else ["-n"]), *args]
7 changes: 5 additions & 2 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ catalogs. It also checks explicit search-model precedence and preservation of an
existing Isaac server when no GPT model is available. These component tests mock
external discovery and the Claude CLI; they do not establish live search coverage.

`test_agent_claude.py` covers OS-managed telemetry ownership and headless configuration. These are
unit/component regressions, not automated Isaac or live telemetry-export coverage.
`test_agent_claude.py`, `test_agent_codex.py`, and `test_managed_files.py` cover headless repair of
conflicting managed settings, compatible/absent no-ops, policy and backup preservation, denied
authorization, and one-shot `sudo -n` with disconnected stdin. These are component regressions with
privileged writes replaced by temporary-file operations, not automated Isaac, real sudo-policy, or
live telemetry-export coverage.

Managed smart defaults are covered by `test_managed_config.py`, `test_cli.py`,
`test_managed_setup.py`, `test_databricks.py`, and `test_managed_budget.py`: parsing the
Expand Down
36 changes: 26 additions & 10 deletions tests/test_agent_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -1264,7 +1264,7 @@ def test_managed_file_wholesale_overwrite_survives_real_reconcile_round_trip(
)
monkeypatch.setattr(claude, "_managed_settings_path", lambda: managed_path)
monkeypatch.setattr(claude, "managed_writes_allowed", lambda: True)
monkeypatch.setattr(managed_files, "managed_writes_allowed", lambda: True)
monkeypatch.setattr(managed_files.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(claude, "backup_existing_file", lambda *a, **kw: True)
monkeypatch.setattr(claude, "save_state", lambda state: None)
monkeypatch.setattr(claude, "ug_version", lambda: "1.0")
Expand Down Expand Up @@ -1326,7 +1326,7 @@ def _write(target, text):
monkeypatch.setattr(managed_files, "_sudo_remove", lambda *a: sudo_writes.append("remove"))
monkeypatch.setattr(claude, "_managed_settings_path", lambda: managed_path)
monkeypatch.setattr(claude, "managed_writes_allowed", lambda: True)
monkeypatch.setattr(managed_files, "managed_writes_allowed", lambda: True)
monkeypatch.setattr(managed_files.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(claude, "backup_existing_file", lambda *a, **kw: True)
monkeypatch.setattr(claude, "save_state", lambda state: None)
monkeypatch.setattr(claude, "ug_version", lambda: "1.0")
Expand Down Expand Up @@ -1968,20 +1968,29 @@ def test_noninteractive_uses_local_settings_when_managed_file_is_compatible(self

assert managed_writes == []

def test_noninteractive_fails_when_managed_file_conflicts(self, monkeypatch):
def test_noninteractive_repairs_conflicting_managed_settings_without_prompting(
self, monkeypatch
):
private_writes: list = []
managed_writes: list = []
existing = {
str(FAKE_MANAGED_PATH): {"env": {"ANTHROPIC_BASE_URL": "https://other.example.com"}}
str(FAKE_MANAGED_PATH): {
"apiKeyHelper": "isaac auth token",
"env": {"ANTHROPIC_BASE_URL": "https://other.example.com", "ISAAC_ONLY": "keep"},
"permissions": {"deny": ["Read(secret.txt)"]},
}
}
self._patch(monkeypatch, private_writes, managed_writes, existing)
monkeypatch.setattr(claude, "managed_writes_allowed", lambda: False)
state = {"workspace": WS, "codex_models": []}

with pytest.raises(RuntimeError, match="cannot be applied non-interactively"):
claude.write_tool_config(state, "databricks-claude-sonnet-4")
claude.write_tool_config({"workspace": WS, "codex_models": []}, None)

assert managed_writes == []
assert len(managed_writes) == 1
written = json.loads(managed_writes[0][1])
assert written["apiKeyHelper"] == private_writes[0][1]["apiKeyHelper"]
assert written["env"]["ANTHROPIC_BASE_URL"] == f"{WS}/ai-gateway/anthropic"
assert written["env"]["ISAAC_ONLY"] == "keep"
assert "Read(secret.txt)" in written["permissions"]["deny"]

def test_headless_isaac_headers_and_telemetry_are_compatible(self, monkeypatch):
private_writes: list = []
Expand Down Expand Up @@ -2065,7 +2074,12 @@ def test_explicit_ug_tracing_still_rejects_conflicting_managed_telemetry(self, m
)
monkeypatch.setattr(claude, "managed_writes_allowed", lambda: False)

with pytest.raises(RuntimeError, match="env.OTEL_TRACES_EXPORTER"):
def deny_managed_write(*args, **kwargs):
raise managed_files.ManagedFileWriteUnavailable("sudo denied")

monkeypatch.setattr(claude, "reconcile_managed_file", deny_managed_write)

with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="sudo denied"):
claude.write_tool_config(
{"workspace": WS, "codex_models": [], "claude_otel_tracing": True}, None
)
Expand Down Expand Up @@ -2103,13 +2117,15 @@ def deny_managed_write(*args, **kwargs):
assert "continuing with local settings" in warnings[0]
assert verified == [{"scope": "local-compatible"}]

def test_sudo_failure_remains_fatal_when_managed_file_conflicts(self, monkeypatch):
@pytest.mark.parametrize("interactive", [False, True])
def test_sudo_failure_remains_fatal_when_managed_file_conflicts(self, monkeypatch, interactive):
private_writes: list = []
managed_writes: list = []
existing = {
str(FAKE_MANAGED_PATH): {"env": {"ANTHROPIC_BASE_URL": "https://other.example.com"}}
}
self._patch(monkeypatch, private_writes, managed_writes, existing)
monkeypatch.setattr(claude, "managed_writes_allowed", lambda: interactive)

def deny_managed_write(*args, **kwargs):
raise managed_files.ManagedFileWriteUnavailable("sudo denied")
Expand Down
Loading
Loading