From 39f6618f93be572ba3d5edea342345a626363415 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Tue, 14 Jul 2026 21:28:11 -0700 Subject: [PATCH] fix(cli): write autoroute's secret-bearing files with mode 0600 commands.py wrote config.yaml (embeds the real proxy key) and Claude Code's settings.json (embeds the ephemeral proxy's master key) with plain open(), landing at the umask-derived default (commonly 0644) until a later chmod call caught up. That window, and the missed case where settings.json already exists (chmod never ran at all there), left a credential-bearing file readable by another local account. secure_create() fixes the mode via fchmod on the fd before any content is written, covering both the brand-new-file and already-exists cases, and commands.py/wizard.py now route their sensitive writes through it. --- .../client/cli/commands/autoroute/commands.py | 6 ++--- .../client/cli/commands/autoroute/process.py | 24 +++++++++++++++++++ .../client/cli/commands/autoroute/wizard.py | 5 ++-- .../client/cli/autoroute/test_commands.py | 4 ++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/client/cli/commands/autoroute/commands.py b/litellm/proxy/client/cli/commands/autoroute/commands.py index cbc07456ac2..82ba047ec9d 100644 --- a/litellm/proxy/client/cli/commands/autoroute/commands.py +++ b/litellm/proxy/client/cli/commands/autoroute/commands.py @@ -23,6 +23,7 @@ from .process import ( launch_proxy, poll_liveliness, read_pid_record, + secure_create, stream_log, terminate, write_pid_record, @@ -52,9 +53,8 @@ def _mint_and_embed_master_key() -> str: "master_key": master_key, } updated: dict[str, JsonValue] = {**generated, "general_settings": updated_settings} - with open(CONFIG_PATH, "w") as f: + with secure_create(CONFIG_PATH) as f: yaml.safe_dump(updated, f, sort_keys=False) - CONFIG_PATH.chmod(0o600) return master_key @@ -103,7 +103,7 @@ def up() -> None: ) merged = merge_claude_settings_static_token(original_settings, base_url, master_key) CLAUDE_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(CLAUDE_SETTINGS_PATH, "w") as f: + with secure_create(CLAUDE_SETTINGS_PATH) as f: json.dump(merged, f, indent=2) click.echo(f"litellm: ephemeral auto-router proxy up at {base_url} (pid {process.pid})") diff --git a/litellm/proxy/client/cli/commands/autoroute/process.py b/litellm/proxy/client/cli/commands/autoroute/process.py index 40d0585cfed..c8de68307b9 100644 --- a/litellm/proxy/client/cli/commands/autoroute/process.py +++ b/litellm/proxy/client/cli/commands/autoroute/process.py @@ -9,6 +9,7 @@ import threading import time from dataclasses import dataclass from pathlib import Path +from typing import IO, Iterator import click import requests @@ -20,6 +21,28 @@ LOG_PATH = AUTOROUTE_DIR / "proxy.log" PID_RECORD_PATH = AUTOROUTE_DIR / "proxy.pid.json" +@contextlib.contextmanager +def secure_create(path: Path) -> Iterator[IO[str]]: + """Open path for writing with mode 0600 fixed up before any content is written. + + A plain `open(path, "w")` creates a *new* file at the umask-derived default (commonly 0644) + and leaves it world- or group-readable until a later `chmod` call catches up -- a real window + in which a file holding a credential (a proxy master key, a Claude Code auth token) is readable + by another local account. Passing the mode to `os.open` closes that window for a brand-new + file, but `O_CREAT`'s mode argument is only applied on creation: if the file already exists + (the common case for `~/.claude/settings.json`, which normally predates `lite autoroute up`) + its old, broader permissions carry over untouched. `os.fchmod` right after opening -- before a + single byte of the new content is written -- covers both cases. + """ + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + os.fchmod(fd, 0o600) + f: IO[str] = os.fdopen(fd, "w") + try: + yield f + finally: + f.close() + + class ProcessLaunchError(Exception): """Raised when the ephemeral proxy subprocess fails to come up healthy.""" @@ -151,6 +174,7 @@ __all__ = [ "launch_proxy", "poll_liveliness", "read_pid_record", + "secure_create", "stream_log", "terminate", "write_pid_record", diff --git a/litellm/proxy/client/cli/commands/autoroute/wizard.py b/litellm/proxy/client/cli/commands/autoroute/wizard.py index 06a6ea7d2b1..b9f8a5408b5 100644 --- a/litellm/proxy/client/cli/commands/autoroute/wizard.py +++ b/litellm/proxy/client/cli/commands/autoroute/wizard.py @@ -22,7 +22,7 @@ from .config import ( parse_discovered_models, validate_config, ) -from .process import CONFIG_PATH +from .process import CONFIG_PATH, secure_create def _is_interactive() -> bool: @@ -113,9 +113,8 @@ def run_configure_wizard(ctx: click.Context) -> Path: model_list = build_generated_model_list(config) CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(CONFIG_PATH, "w") as f: + with secure_create(CONFIG_PATH) as f: yaml.safe_dump({"model_list": model_list}, f, sort_keys=False) - CONFIG_PATH.chmod(0o600) click.echo(f"\nWrote {CONFIG_PATH}") for tier, models in tiers.items(): diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_commands.py b/tests/test_litellm/proxy/client/cli/autoroute/test_commands.py index 098659d5e8b..9737e49c4ae 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_commands.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_commands.py @@ -1,4 +1,5 @@ import json +import stat from typing import Optional import yaml @@ -90,6 +91,7 @@ class TestUpCommand: def fake_wait(self, timeout=None): captured["settings"] = json.loads(claude_settings_path.read_text()) captured["backup_existed"] = backup_path.exists() + captured["settings_mode"] = stat.S_IMODE(claude_settings_path.stat().st_mode) return True monkeypatch.setattr("threading.Event.wait", fake_wait) @@ -102,6 +104,7 @@ class TestUpCommand: assert captured["settings"]["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:54321" assert captured["settings"]["env"]["ANTHROPIC_AUTH_TOKEN"] == "fixed-master-key" assert "apiKeyHelper" not in captured["settings"] + assert captured["settings_mode"] == 0o600 assert terminate_calls == [99999] assert not pid_record_path.exists() @@ -110,6 +113,7 @@ class TestUpCommand: written_config = yaml.safe_load(config_path.read_text()) assert written_config["general_settings"]["master_key"] == "fixed-master-key" + assert stat.S_IMODE(config_path.stat().st_mode) == 0o600 def test_surfaces_clean_error_and_cleans_up_when_health_check_fails(self, monkeypatch, tmp_path): config_path, _log_path, claude_settings_path, backup_path, pid_record_path = _patch_paths(monkeypatch, tmp_path)