"""Human + JSON output and a security-grade redaction filter. Every byte the CLI ever writes to stdout / stderr passes through :func:`redact` first — even debug traces under ``HELIX_DEBUG=1``. That is the single chokepoint that prevents a future code change from accidentally leaking a PAT into the user's terminal scrollback, their shell history (when piped to a logger), or a CI build artifact. One deliberate carve-out (launch audit, 2026-06): the *result payload* of an explicit reveal-tool invocation (``helix mcp call reveal_api_key`` — see ``main._REVEAL_OUTPUT_TOOLS``) skips the credential-shape redaction, because the credential IS the answer the user asked for. That payload still passes the terminal-control strip — a revealed value must never be able to rewrite the terminal. The contract is therefore: "every byte, except the result payload of an explicit reveal-tool invocation, which still gets terminal-escape stripping." Errors, warnings, and every other tool's output keep full redaction. """ from __future__ import annotations import json import re import sys from typing import Any, TextIO from helix_cli.config import PAT_PREFIX # ── Redaction patterns ──────────────────────────────────────────────── # Each pattern below is anchored on a recognisable opening token so the # match never devours arbitrary surrounding text. Order doesn't matter # (we run them all serially); each pattern's replacement returns a # fixed marker so a recurring secret only redacts once and stays # stable in test assertions. #: Bare PAT — ``helix_pat_`` followed by base32-ish body. Bounded by #: 16-128 chars to avoid pathological matches over a full-document #: regex pass. We grab anything word-y after the prefix. #: Body alphabet is intentionally permissive (``[A-Za-z0-9_\-]``) so a #: future server-side change to PAT minting (urlsafe-b64 → contains ``-`` #: and ``_``) doesn't silently start leaking tokens past the redactor. _PAT_RE = re.compile(rf"{re.escape(PAT_PREFIX)}[A-Za-z0-9_\-]{{8,128}}") #: C0 control codes that an attacker-controlled server-returned string #: could carry into the user's terminal. Specifically targets the ESC #: byte (0x1b) so an asset note like ``\x1b[2J`` cannot clear the #: user's screen / fake a prompt / move the cursor. We also drop the #: bidi-override codepoints so a malicious note can't visually re-order #: itself in the CLI viewer. Tabs (9) and newlines (10) are left intact — #: legitimate output uses them. Bare CR (13 / 0x0D) is stripped: it #: returns the cursor to column 0, enabling a server-returned string to #: overwrite previously printed lines ("line hijack"). The MCP server's #: runtime._CTRL_TABLE already strips CR on the outbound path; this is #: a defence-in-depth layer at the last hop before the real terminal. _TERMINAL_STRIP_TABLE = { c: None for c in [*range(0, 9), 11, 12, 13, *range(14, 32), 127] } | { # C1 control codes (U+0080–U+009F). xterm-class terminals honour # these even in UTF-8 mode: U+009B is a one-character CSI (no ESC # byte needed) and U+009D opens an OSC, so a server-returned string # carrying them can rewrite the user's terminal exactly like the # C0 ESC sequences we already strip above. (Launch audit, 2026-06.) c: None for c in range(0x80, 0xA0) } | { c: None for c in ( 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069, ) } def _strip_terminal_escapes(value: str) -> str: """Strip C0/C1 controls + bidi overrides before printing to a TTY. The MCP server's ``runtime.serialize`` already strips these on the outbound path, but the CLI is the last hop to a real terminal — defense in depth in case a future server-side regression skips the sanitiser, or the CLI ever talks to a service that doesn't. """ if not value: return value return value.translate(_TERMINAL_STRIP_TABLE) #: ``Bearer `` — fired anywhere the token appears, not just #: after ``Authorization:``. This covers stack traces / debug logs / #: dict reprs alike (``"Authorization': 'Bearer xxx'"`` from a Python #: dict ``__repr__`` would otherwise slip past a stricter pattern that #: required a literal colon between ``Authorization`` and ``Bearer``). _BEARER_RE = re.compile( r"(?i)\b(bearer\s+)([A-Za-z0-9._\-+/=]{8,})", ) #: JWT shape: three base64url segments joined by dots. Conservative #: lower-bound on segment length so this doesn't hit version strings #: like "1.2.3"; floor of 8 is below any real-world Helix-issued JWT #: (HS256 signature alone is ≥43 chars) but covers shorter test JWTs. _JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b") #: JSON / dict-style key=value pairs naming a secret-bearing field. #: We do a generic catch on values up to 256 chars (covers PATs, #: short-lived tokens, API keys). Keys are matched case-insensitive. _SECRET_KEYS = ( "token", "access_token", "refresh_token", "id_token", "api_key", "apikey", "secret", "client_secret", "private_key", "password", "passwd", ) #: Note the ``["']?`` between the key word and the separator: in a JSON #: dump the key is followed by a closing quote (``"api_key": "..."``), #: in a YAML / dict repr it isn't. Both forms must redact. _SECRET_KEY_RE = re.compile( r"(?i)(\b(?:" + "|".join(re.escape(k) for k in _SECRET_KEYS) + r")\b" r"[\"']?\s*[:=]\s*)" r"(\"[^\"]{1,512}\"|'[^']{1,512}'|[^\s,;}]{1,512})", ) def _redact_secret_kv(match: re.Match[str]) -> str: """Substitute a secret key/value match while preserving the quoting style of the original value. This matters in ``--json`` output: the regex's value capture includes surrounding quotes when present (``"key": "value"``), so a naive ``"\\1"`` substitution would emit ``"key": `` — invalid JSON. We detect the original quoting and emit ``"key": ""`` / ``'key': ''`` / ``key=`` so machine-readable output stays parseable. """ prefix = match.group(1) value = match.group(2) if value.startswith('"') and value.endswith('"'): return f'{prefix}""' if value.startswith("'") and value.endswith("'"): return f"{prefix}''" return f"{prefix}" def redact(value: str) -> str: """Apply every redaction pattern to ``value`` and return the result. Designed to be safe to call on anything stringifiable; non-string inputs are coerced via ``str(...)``. Each pattern leaves an obvious marker so an operator reading redacted output can tell *what* was redacted without needing to see the secret itself. Also strips terminal control / bidi-override codepoints from the final string so a server-returned asset note carrying ``\\x1b[2J`` or RTL-override glyphs cannot rewrite the user's terminal view. """ if not isinstance(value, str): value = str(value) out = value out = _PAT_RE.sub(f"{PAT_PREFIX}", out) out = _BEARER_RE.sub(r"\1", out) out = _JWT_RE.sub("", out) out = _SECRET_KEY_RE.sub(_redact_secret_kv, out) out = _strip_terminal_escapes(out) return out # ── Output sinks ────────────────────────────────────────────────────── def _stream(default: TextIO, kind: str) -> TextIO: """Module-level stream getter used so tests can patch ``sys.stdout`` / ``sys.stderr`` and have the CLI honour the patch. """ return getattr(sys, kind, default) def write_out(text: str, *, redact_secrets: bool = True) -> None: """Write a redacted line to stdout. ``redact_secrets=False`` is the reveal-tool carve-out documented in the module docstring: skip the credential-shape patterns (the value is the answer) but ALWAYS run the terminal-control strip — a revealed credential must not be able to clear the screen / fake a prompt any more than an asset note can. """ fh = _stream(sys.stdout, "stdout") if redact_secrets: cleaned = redact(text) else: cleaned = _strip_terminal_escapes( text if isinstance(text, str) else str(text) ) fh.write(cleaned) if not text.endswith("\n"): fh.write("\n") try: fh.flush() except Exception: # noqa: BLE001 pass def write_err(text: str) -> None: """Write a redacted line to stderr.""" fh = _stream(sys.stderr, "stderr") fh.write(redact(text)) if not text.endswith("\n"): fh.write("\n") try: fh.flush() except Exception: # noqa: BLE001 pass def emit_json(data: Any, *, redact_secrets: bool = True) -> None: """Pretty-print ``data`` as JSON to stdout, redacted. On a circular reference (server-side bug) ``json.dumps`` raises ``ValueError`` — surface that as a structured error rather than letting the catch-all in ``main`` map it to EXIT_NETWORK and confuse users with a "network error" message. ``redact_secrets`` threads through to :func:`write_out` (reveal- tool carve-out); the warning sidecar below stays fully redacted — only the payload itself is exempted. """ try: payload = json.dumps(data, indent=2, ensure_ascii=False, default=str) except (TypeError, ValueError) as exc: # Circular ref / un-serialisable type. Don't lose the data — # emit a placeholder via repr() so the user still sees something. write_err( f"helix: warning: response could not be JSON-encoded " f"({type(exc).__name__}: {exc}); falling back to repr.", ) payload = json.dumps( {"__unserializable__": True, "repr": repr(data)[:4000]}, indent=2, ensure_ascii=False, ) write_out(payload, redact_secrets=redact_secrets) def emit_human(data: Any, *, redact_secrets: bool = True) -> None: """Render ``data`` for a terminal reader. Trades fidelity for legibility: dicts become aligned key/value blocks, lists become enumerated rows, scalars print as-is. For anything fancy callers should use ``--json`` instead — there is intentionally no table-rendering library dependency here, the CLI's contract is "machine-readable JSON or a plain-English summary," not "rich TUI." ``redact_secrets`` threads through to :func:`write_out` (reveal- tool carve-out). """ write_out(_format_human(data), redact_secrets=redact_secrets) def _format_human(data: Any, indent: int = 0) -> str: pad = " " * indent if data is None: return f"{pad}" if isinstance(data, bool): return f"{pad}{'yes' if data else 'no'}" if isinstance(data, (int, float, str)): text = str(data) return "\n".join(f"{pad}{line}" for line in text.splitlines() or [""]) if isinstance(data, dict): if not data: return f"{pad}" # Common envelope unwrap: ``{"items": [...], ...}`` from list-style # tools. Print the items list inline, then any siblings as a # footer. if "items" in data and isinstance(data["items"], list): lines = [] lines.append(_format_human(data["items"], indent)) sidecar = {k: v for k, v in data.items() if k != "items"} if sidecar: lines.append("") lines.append(f"{pad}meta:") lines.append(_format_human(sidecar, indent + 1)) return "\n".join(lines) lines = [] for k, v in data.items(): if isinstance(v, (dict, list)): lines.append(f"{pad}{k}:") lines.append(_format_human(v, indent + 1)) else: lines.append(f"{pad}{k}: {v}") return "\n".join(lines) if isinstance(data, list): if not data: return f"{pad}" lines = [] for i, item in enumerate(data, 1): if isinstance(item, dict): # Pick a "title" field if one looks obvious so each # row gets a leading anchor. title = ( item.get("name") or item.get("display_name") or item.get("title") or item.get("id") or f"#{i}" ) lines.append(f"{pad}- {title}") lines.append(_format_human(item, indent + 1)) else: lines.append(f"{pad}- {_format_human(item, 0).strip()}") return "\n".join(lines) # Fallback for objects we don't recognise — coerce via ``repr`` and # let redaction strip anything dangerous before it leaves. return f"{pad}{data!r}" def emit_error(message: str, *, code: str | None = None) -> None: """Render an error to stderr in a stable, redacted shape.""" if code: write_err(f"helix: error: [{code}] {message}") else: write_err(f"helix: error: {message}") __all__ = [ "redact", "write_out", "write_err", "emit_json", "emit_human", "emit_error", ]