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.
This commit is contained in:
Krrish Dholakia 2026-07-14 21:28:11 -07:00
parent d4ad521845
commit 39f6618f93
4 changed files with 33 additions and 6 deletions

View file

@ -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})")

View file

@ -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",

View file

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

View file

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