"""CLI configuration: where the base URL and PAT come from. Three layers, highest precedence first: 1. **CLI flag** (``--base-url`` only — there is intentionally no ``--token`` flag because tokens leak into shell history and ``ps(1)`` listings). 2. **Environment variables** — ``HELIX_BASE_URL`` and ``HELIX_PAT`` / ``HELIX_TOKEN``. 3. **Persistent config file** — JSON at ``~/.config/helix/config.json`` (overridable via ``HELIX_CONFIG`` for tests / CI / multi-profile workflows). Token storage on disk is mode ``0o600`` and the ``config show`` surface only ever reveals the prefix (``helix_pat_ab****``); the full plaintext is never logged or echoed. """ from __future__ import annotations import json import os import re import stat import sys from dataclasses import dataclass from pathlib import Path from urllib.parse import urlsplit # Mirror :data:`app.auth.tokens.service.PAT_PREFIX` without importing # from there. The CLI module must be importable without the heavy # server stack (FastAPI / SQLAlchemy / asyncmy) being installed; a # direct import would pull in the whole ``app.auth`` tree at every # ``helix --help`` invocation. PAT_PREFIX = "helix_pat_" #: Hard cap on PAT length we are willing to accept from any source. #: Real PATs are ``len(PAT_PREFIX) + 32 base32 chars`` = 42 bytes; #: anything larger is almost certainly a paste of an unrelated secret #: (a JWT, an API response body) and we refuse to persist it. MAX_PAT_LEN = 256 DEFAULT_BASE_URL = "http://localhost:8000" def _config_path() -> Path: """Resolve the config file path. Honours ``HELIX_CONFIG`` first (used by tests + multi-profile setups) and otherwise falls back to ``$XDG_CONFIG_HOME/helix`` → ``~/.config/helix``. """ override = os.environ.get("HELIX_CONFIG") if override: return Path(override).expanduser() base = os.environ.get("XDG_CONFIG_HOME") if base: return Path(base).expanduser() / "helix" / "config.json" return Path.home() / ".config" / "helix" / "config.json" def _read_config_text(path: Path, *, for_write: bool = False) -> str | None: """Read the raw config file text. ``None`` means "file missing". The ``for_write`` distinction is load-bearing (launch audit, 2026-06): mutation paths (``set_base_url`` / ``set_token`` / ``clear_token``) follow a read-modify-ATOMIC-REWRITE pattern, so an *existing but unreadable* file (root-owned, chmod 000) silently degrading to ``{}`` meant the subsequent rewrite destroyed every key we couldn't read — including the saved token. Write paths must therefore ABORT on an unreadable-but-existing file. Pure read paths keep degrading to "unset" but leave a one-line stderr breadcrumb (mirroring the corrupt-JSON warning below) so the user isn't mysteriously logged out with no signal. """ if not path.exists(): return None try: return path.read_text(encoding="utf-8") except FileNotFoundError: # Deleted between the exists() check and the open — same as # never having existed. return None except OSError as exc: if for_write: raise OSError( f"config file at {path} exists but is unreadable " f"(permissions?) — refusing to rewrite it; a blind " f"rewrite would silently drop every key we couldn't " f"read (including a saved token). Underlying error: " f"{type(exc).__name__}: {exc}" ) from exc try: sys.stderr.write( f"helix: warning: config file at {path} exists but is " f"unreadable ({type(exc).__name__}); treating as unset.\n" ) sys.stderr.flush() except Exception: # noqa: BLE001 pass return None def _load_file(path: Path, *, for_write: bool = False) -> dict: raw = _read_config_text(path, for_write=for_write) if raw is None: return {} try: data = json.loads(raw) except json.JSONDecodeError as exc: # Corruption is not a normal "missing" state. A user appearing # logged-out with no signal will burn their first hour of # support time; a one-line stderr breadcrumb saves it. We # still return ``{}`` so the rest of the CLI keeps working. try: sys.stderr.write( f"helix: warning: config file at {path} is corrupted " f"({exc.msg} at line {exc.lineno}); treating as unset. " f"Run `helix auth token set --token-stdin` to recover.\n" ) sys.stderr.flush() except Exception: # noqa: BLE001 pass return {} if not isinstance(data, dict): return {} return data def _is_helix_owned_path(path: Path) -> bool: """True when ``path`` is the default helix config directory. Used to decide whether ``_atomic_write`` is allowed to chmod the parent directory. A user who points ``HELIX_CONFIG`` at a custom location (e.g. ``/tmp/foo.json`` for a multi-profile setup) does NOT want us to silently flip ``/tmp`` to ``0o700`` and lock every other process out of it. """ parent = path.parent.resolve() candidates: list[Path] = [] base = os.environ.get("XDG_CONFIG_HOME") if base: candidates.append(Path(base).expanduser() / "helix") candidates.append(Path.home() / ".config" / "helix") for c in candidates: try: if parent == c.resolve(): return True except (OSError, RuntimeError): continue return False def _atomic_write(path: Path, payload: str) -> None: """Write ``payload`` to ``path`` with ``0o600`` permissions, atomic. Atomic so a crash mid-write doesn't leave the user with a half- truncated config file. ``os.open`` with ``O_CREAT|O_EXCL`` on the tmp file and ``os.replace`` to swing it into place is the standard POSIX recipe. """ path.parent.mkdir(parents=True, exist_ok=True) # Lock down the directory too — a world-readable parent makes # ``0o600`` on the file itself less interesting to an attacker who # can list siblings. BUT only when the parent is the helix-owned # config dir; if a user pointed HELIX_CONFIG at e.g. ``/tmp/foo.json`` # we must NOT silently chmod ``/tmp`` to 0o700 and lock the rest of # the system out of it. (Pre-launch security audit, 2026-05-06.) if _is_helix_owned_path(path): try: os.chmod(path.parent, 0o700) except OSError: # Best effort — on Windows / weird filesystems the chmod can # fail, and we still want the write itself to succeed. pass tmp = path.with_suffix(path.suffix + ".tmp") # ``O_NOFOLLOW`` refuses if ``tmp`` exists as a symlink — defends # against a stale symlink from a previous botched run pointing at # an unrelated file we'd otherwise truncate. ``getattr`` fallback # for Windows where the flag does not exist. flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) # Pre-clear stale tmp files (NOT following symlinks) so the # ``O_NOFOLLOW | O_CREAT`` combination doesn't fail on a leftover # link from a crash. ``os.lstat``/``os.unlink`` operate on the # symlink itself, never its target. try: if os.path.lexists(tmp): os.unlink(tmp) except OSError: pass fd = os.open(tmp, flags, 0o600) try: with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(payload) except Exception: try: tmp.unlink() except OSError: pass raise # Re-chmod after open in case umask widened it (umask 0o002 → # otherwise the file would land 0o644 on some systems). os.chmod(tmp, 0o600) os.replace(tmp, path) def _save_file(path: Path, data: dict) -> None: payload = json.dumps(data, indent=2, sort_keys=True) + "\n" _atomic_write(path, payload) def redact_pat(value: str | None) -> str: """Return a display-safe representation of a PAT. Output shape: ``helix_pat_ab****`` — prefix + first 2 chars of the body + four asterisks. Never reveals more than the first two characters of the secret body, which is what's already stored in the DB display column anyway. Returns ``""`` for empty / ``None``. For unknown-shape tokens (something other than a ``helix_pat_…`` PAT — e.g. a hand-edited config file with a JWT or a raw API key pasted in by mistake) we still return only ``""``; we must NOT echo a shape-derived classification like ```` because pairing the shape hint with a leaked repr / traceback narrows the value space for an attacker who has partial knowledge of the file contents. (R1 code review finding.) """ if not value: return "" if not value.startswith(PAT_PREFIX): return "" body = value[len(PAT_PREFIX):] visible = body[:2] if len(body) >= 2 else body return f"{PAT_PREFIX}{visible}****" @dataclass(frozen=True) class CliConfig: """Resolved view of the CLI's configuration. ``base_url`` is always populated (default :data:`DEFAULT_BASE_URL`); ``token`` may be ``None`` for read-only / probe commands like ``helix health``. """ base_url: str token: str | None base_url_source: str token_source: str def __repr__(self) -> str: # noqa: D401 """Hide the PAT from any accidental print / log / repr. The dataclass-generated ``__repr__`` would otherwise emit ``CliConfig(token='helix_pat_…')`` verbatim — and any future debug ``print(cfg)``, traceback locals capture, or pytest failure dump would leak the token. The ``output.redact()`` filter only covers strings flowing through the CLI's own write_out / write_err sinks; ``__repr__`` is reached by every third-party logger. """ return ( "CliConfig(" f"base_url={self.base_url!r}, " f"token={redact_pat(self.token)!r}, " f"base_url_source={self.base_url_source!r}, " f"token_source={self.token_source!r}" ")" ) @property def mcp_url(self) -> str: """Streamable-HTTP MCP endpoint. Trailing slash is significant: FastMCP is configured with ``streamable_http_path="/"`` and mounted under ``/api/v1/mcp``. A request to ``/api/v1/mcp`` without the trailing slash 307-redirects, which httpx will follow but which can drop the ``Authorization`` header on cross-host redirects. Always send the canonical path. """ return self.base_url.rstrip("/") + "/api/v1/mcp/" def load_config(*, cli_base_url: str | None = None) -> CliConfig: """Resolve final config from CLI flags, env, and persistent file. Precedence (high → low): * **base_url**: ``--base-url`` CLI flag → ``HELIX_BASE_URL`` → file → :data:`DEFAULT_BASE_URL`. * **token**: ``HELIX_PAT`` → ``HELIX_TOKEN`` → file. The ``--token`` flag is intentionally omitted from the precedence chain because tokens passed on argv are world-readable via ``ps(1)`` and recorded in shell history. """ file_data = _load_file(_config_path()) if cli_base_url: base_url = cli_base_url.strip() base_url_source = "flag" elif os.environ.get("HELIX_BASE_URL"): base_url = os.environ["HELIX_BASE_URL"].strip() base_url_source = "env:HELIX_BASE_URL" elif file_data.get("base_url"): base_url = str(file_data["base_url"]).strip() base_url_source = "file" else: base_url = DEFAULT_BASE_URL base_url_source = "default" # Validate the resolved URL regardless of source. _validate_base_url # is only called in set_base_url() (the write path); a URL injected # via HELIX_BASE_URL or a manually edited config file would skip it, # allowing SSRF (e.g. HELIX_BASE_URL=http://169.254.169.254) to send # the PAT to the instance metadata service on the next API call. if base_url_source not in ("default",): try: base_url = _validate_base_url(base_url) except ValueError as exc: # Surface a clear error instead of a confusing 401 or # SSRF-triggered response from an unintended host. raise ValueError( f"Invalid base URL from {base_url_source}: {exc}" ) from exc if os.environ.get("HELIX_PAT"): token: str | None = os.environ["HELIX_PAT"].strip() token_source = "env:HELIX_PAT" elif os.environ.get("HELIX_TOKEN"): token = os.environ["HELIX_TOKEN"].strip() token_source = "env:HELIX_TOKEN" elif file_data.get("token"): token = str(file_data["token"]).strip() token_source = "file" else: token = None token_source = "" if token == "": token = None return CliConfig( base_url=base_url, token=token, base_url_source=base_url_source, token_source=token_source, ) _LOOPBACK_HOSTS = frozenset({ "localhost", "127.0.0.1", "::1", }) def _is_internal_ip_literal(host: str) -> bool: """True if ``host`` is an IP literal in a non-routable / metadata range we must never ship a PAT to. Covers: * IPv4 loopback (``127.0.0.0/8``) — handled separately when scheme is plain http, but we re-block here for https so a user can't bypass by switching scheme on the same loopback target unless they go through the loopback hostname allowlist. * IPv4 link-local / cloud-metadata (``169.254.0.0/16``) — AWS IMDS, GCP/Azure metadata, K8s nodelocal DNS. * IPv4 RFC1918 (``10/8``, ``172.16/12``, ``192.168/16``) — internal corp network endpoints an attacker might point a stolen-config PAT at to map / scan. * IPv4 ``0.0.0.0`` — "any" placeholder. * IPv6 loopback / link-local / unique-local. For DNS hostnames (anything that doesn't parse as an IP literal) we let the call proceed — DNS-based SSRF is broader than the CLI can mitigate; the server-side allowlists handle the upstream surface. """ import ipaddress try: ip = ipaddress.ip_address(host.strip("[]")) except ValueError: return False return ( ip.is_loopback or ip.is_link_local or ip.is_private or ip.is_unspecified or ip.is_multicast or ip.is_reserved ) def _validate_base_url(base_url: str) -> str: """Reject malformed / dangerous base URLs before persisting. Refuses: * URLs not starting with ``http://`` or ``https://`` * URLs containing internal whitespace (paste accidents) * URLs with ``user:pass@`` credentials (would leak credentials in logs / referer headers / process listings) * URLs with a path beyond ``/``, query, or fragment (a copy-paste of ``https://h/api/v1/mcp/`` would otherwise produce an ``mcp_url`` of ``.../api/v1/mcp/api/v1/mcp/``) * Plain ``http://`` to a non-loopback host (the next call would ship the PAT in cleartext over the wire) * ANY scheme (incl. ``https://``) where the host is an IP literal in a non-routable / metadata range — without this an ``HELIX_BASE_URL=https://169.254.169.254`` happily passes validation and the next API call ships the PAT to the cloud metadata service. The hostname-allowlist (loopback / ``*.local``) below specifically permits intentional dev targets. (R1 review finding 2026-05-28.) Returns the normalised URL (trailing slash stripped). """ base_url = base_url.strip() if not base_url: raise ValueError("base_url must not be empty") if any(c.isspace() for c in base_url): raise ValueError("base_url must not contain whitespace") if not (base_url.startswith("http://") or base_url.startswith("https://")): raise ValueError("base_url must start with http:// or https://") parsed = urlsplit(base_url) if parsed.username or parsed.password: raise ValueError( "base_url must not contain credentials (user:pass@host); " "use `helix auth token set --token-stdin` to configure auth.", ) if not parsed.hostname: raise ValueError("base_url is missing a hostname") if parsed.path not in ("", "/"): raise ValueError( "base_url must be the host root, not include a path " f"(got path={parsed.path!r}); the CLI appends /api/v1/mcp/ itself.", ) if parsed.query or parsed.fragment: raise ValueError("base_url must not include a query string or fragment") host = (parsed.hostname or "").lower() if parsed.scheme == "http": if host not in _LOOPBACK_HOSTS and not host.endswith(".local"): raise ValueError( "Plain http:// is only allowed for loopback hosts " "(localhost / 127.0.0.1 / ::1 / *.local). Use https:// " "for remote hosts so the PAT isn't sent in cleartext.", ) # IP-literal SSRF block applies regardless of scheme — refusing # https://169.254.169.254 etc. The loopback ALLOWLIST above # already permits intentional ``127.0.0.1`` / ``::1`` / ``localhost`` # for local dev, so this only fires for non-loopback IP literals # that fall in dangerous ranges. if ( host not in _LOOPBACK_HOSTS and _is_internal_ip_literal(host) ): raise ValueError( f"base_url host {host!r} is in a non-routable / metadata " "IP range; the CLI refuses to ship the PAT to internal " "endpoints. Use the public hostname.", ) # Strip trailing slash for canonical storage; ``mcp_url`` re-appends # the API path with a trailing slash itself. return base_url.rstrip("/") def set_base_url(base_url: str) -> Path: """Persist a new base URL into the config file. Raises :class:`ValueError` on any URL the CLI refuses to use. """ normalised = _validate_base_url(base_url) path = _config_path() # ``for_write=True``: an existing-but-unreadable file must abort # rather than be blindly rewritten minus the keys we couldn't read. data = _load_file(path, for_write=True) if not data: # Possibly a corrupt-JSON file (readable but unparseable → # ``{}``): rewriting would silently drop a saved PAT. Salvage # it the same way ``clear_token`` salvages ``base_url`` — # regex the raw bytes and keep the value only if it still # looks like a FULL-LENGTH PAT (same ``_MIN_PAT_LEN`` floor # ``set_token`` enforces — never re-persist a truncated # credential ``set_token`` would have refused). The salvage # fires only when the parse yielded NOTHING: a valid-JSON # file that merely lacks a top-level token must not have a # nested look-alike hoisted into the active credential slot. # (Launch audit follow-up, 2026-06-10/11.) raw = _read_config_text(path, for_write=False) if raw: try: json.loads(raw) parse_failed = False except json.JSONDecodeError: parse_failed = True if parse_failed: m = re.search( rf'"token"\s*:\s*' rf'"({re.escape(PAT_PREFIX)}[A-Za-z0-9_\-]{{32,128}})"', raw, ) if m: data["token"] = m.group(1) data["base_url"] = normalised _save_file(path, data) return path #: Smallest plausible PAT length: prefix + 32 chars. Real Helix PATs #: are minted at ``PAT_PREFIX + 32`` base32 chars (see #: ``app.auth.tokens.service.PersonalAccessTokenService.mint``). A #: shorter token cannot be a real PAT — accepting it just defers a #: 401 on every subsequent request and burns the user's support time. _MIN_PAT_LEN = len(PAT_PREFIX) + 32 def set_token(token: str) -> Path: """Persist a PAT into the config file. Validates prefix + length. Validation is local-only — the CLI does NOT verify the token against the server here, because (a) ``set`` should work offline and (b) we don't want a non-routable / unreachable server to block someone from saving a token they know is good. Refuses tokens containing internal whitespace: ``stdin.read().strip()`` only trims the ends, so a multi-line paste that wraps the PAT around a newline would otherwise persist a token with an embedded ``\\n``. The server's strict prefix check would then reject it on every request and the user would see a generic 401, with no clue that the config file is what's wrong. Reject up front instead. """ token = token.strip() if not token: raise ValueError("token must not be empty") if any(c.isspace() for c in token): raise ValueError( "token must not contain whitespace — paste accident? " "Re-copy the PAT from /me/settings → API tokens.", ) if not token.startswith(PAT_PREFIX): raise ValueError( f"token must start with {PAT_PREFIX!r}; got something else" ) if len(token) < _MIN_PAT_LEN: raise ValueError( f"token is suspiciously short (<{_MIN_PAT_LEN} chars) — " "paste was likely truncated.", ) if len(token) > MAX_PAT_LEN: raise ValueError(f"token is suspiciously long (>{MAX_PAT_LEN} chars)") path = _config_path() # ``for_write=True``: an existing-but-unreadable file must abort # rather than be blindly rewritten minus the keys we couldn't read. data = _load_file(path, for_write=True) data["token"] = token _save_file(path, data) return path def clear_token() -> tuple[Path, str]: """Wipe the persisted token, honestly. Returns ``(path, outcome)`` where ``outcome`` is one of: * ``"cleared"`` — token key existed and was removed. * ``"rewrote-corrupt"`` — the file was corrupt JSON. The old behaviour silently no-opped here (``_load_file`` → ``{}`` → "token" not present → nothing written) while the command layer still printed "cleared token" — leaving the plaintext PAT bytes on disk. Now we REWRITE the corrupt file without the token, salvaging ``base_url`` when it is regex-recoverable (and passes validation), so the credential is actually gone. * ``"absent"`` — file missing or no token key; nothing written. An existing-but-unreadable file raises ``OSError`` (see :func:`_read_config_text`) instead of pretending success. """ path = _config_path() raw = _read_config_text(path, for_write=True) if raw is None: return path, "absent" parsed: dict | None try: loaded = json.loads(raw) parsed = loaded if isinstance(loaded, dict) else None except json.JSONDecodeError: parsed = None if parsed is None: # Corrupt (or non-object) file. Preserving nothing is fine for # a corrupt file, but salvage base_url when recoverable so the # user doesn't also lose their server pointer. Only persist it # if it still passes validation — never launder a bad URL out # of a corrupt file into a clean one. salvaged: dict = {} m = re.search(r'"base_url"\s*:\s*"([^"\\]+)"', raw) if m: try: salvaged["base_url"] = _validate_base_url(m.group(1)) except ValueError: pass _save_file(path, salvaged) return path, "rewrote-corrupt" if "token" not in parsed: return path, "absent" parsed.pop("token", None) _save_file(path, parsed) return path, "cleared" def file_mode_octal(path: Path) -> str | None: """Return the file's permission bits as an octal string, or ``None`` if the file doesn't exist. Used by ``config show`` for an at-a-glance security check. """ try: st = path.stat() except FileNotFoundError: return None return oct(stat.S_IMODE(st.st_mode)) def config_path() -> Path: """Public accessor for the resolved config path.""" return _config_path() __all__ = [ "PAT_PREFIX", "DEFAULT_BASE_URL", "MAX_PAT_LEN", "CliConfig", "redact_pat", "load_config", "set_base_url", "set_token", "clear_token", "file_mode_octal", "config_path", ]