diff --git a/cli/engram/commands/web.py b/cli/engram/commands/web.py index fbb86f8..cb446ff 100644 --- a/cli/engram/commands/web.py +++ b/cli/engram/commands/web.py @@ -2,11 +2,21 @@ A stdlib-only, server-rendered browser for the store. Read-only in this cut; mutations stay on the CLI / MCP. Binds 127.0.0.1 by default; a -non-loopback bind requires ``--auth`` (DESIGN §7.4, no bypass). +non-loopback bind requires auth (DESIGN §7.4, no bypass). + +Credentials arrive by one of two routes. ``--auth USER:PASS`` is the +convenient one and stays for single-user hosts. ``--auth-file PATH`` is +the one to use anywhere else: on a shared machine the command line is +world-readable through ``ps``/``/proc``, so a password passed as an +argument is visible to every other account on the box. The file route +keeps the secret behind filesystem permissions instead. """ from __future__ import annotations +import stat +from pathlib import Path + import click from engram.config_types import GlobalConfig @@ -15,6 +25,45 @@ __all__ = ["web_group"] +def _parse_auth_pair(raw: str, source: str) -> tuple[str, str]: + """Split ``USER:PASS``. ``source`` names the origin for error text.""" + if ":" not in raw: + raise click.ClickException(f"{source} must be USER:PASS") + user, _, password = raw.partition(":") + if not user or not password: + raise click.ClickException(f"{source} must be USER:PASS (both non-empty)") + return (user, password) + + +def _read_auth_file(path: str) -> tuple[str, str]: + """Read ``USER:PASS`` from the first non-empty line of ``path``. + + Warns when the file is readable beyond its owner: the whole point of + this route is that the secret sits behind file permissions, so a + world-readable credential file silently gives back what was gained. + """ + p = Path(path) + try: + mode = p.stat().st_mode + except OSError as exc: + raise click.ClickException(f"--auth-file: cannot stat {path}: {exc}") from exc + if mode & (stat.S_IRWXG | stat.S_IRWXO): + click.echo( + f"warning: {path} is readable beyond its owner " + f"(mode {oct(stat.S_IMODE(mode))}); run: chmod 600 {path}", + err=True, + ) + try: + text = p.read_text(encoding="utf-8") + except OSError as exc: + raise click.ClickException(f"--auth-file: cannot read {path}: {exc}") from exc + for line in text.splitlines(): + line = line.strip() + if line: + return _parse_auth_pair(line, "--auth-file content") + raise click.ClickException(f"--auth-file: {path} is empty") + + @click.group("web", help="Local web UI for browsing the engram store (SPEC §7).") def web_group() -> None: pass @@ -27,22 +76,36 @@ def web_group() -> None: "--auth", default=None, metavar="USER:PASS", - help="Enable HTTP Basic auth. Required for a non-loopback --host.", + help="Enable HTTP Basic auth. Required for a non-loopback --host. " + "Visible to other accounts via ps on a shared host — prefer --auth-file there.", +) +@click.option( + "--auth-file", + "auth_file", + default=None, + metavar="PATH", + help="Read USER:PASS from the first non-empty line of PATH. " + "Use this instead of --auth on any multi-user machine.", ) @click.option("--no-open", is_flag=True, default=False, help="Do not open a browser.") @click.pass_obj def serve_cmd( - cfg: GlobalConfig, host: str, port: int, auth: str | None, no_open: bool + cfg: GlobalConfig, + host: str, + port: int, + auth: str | None, + auth_file: str | None, + no_open: bool, ) -> None: root = cfg.resolve_project_root() + if auth is not None and auth_file is not None: + raise click.ClickException("pass either --auth or --auth-file, not both") + auth_pair: tuple[str, str] | None = None - if auth is not None: - if ":" not in auth: - raise click.ClickException("--auth must be USER:PASS") - user, _, password = auth.partition(":") - if not user or not password: - raise click.ClickException("--auth must be USER:PASS (both non-empty)") - auth_pair = (user, password) + if auth_file is not None: + auth_pair = _read_auth_file(auth_file) + elif auth is not None: + auth_pair = _parse_auth_pair(auth, "--auth") try: httpd = serve(root, host=host, port=port, auth=auth_pair, open_browser=not no_open)