"""Regression tests for the 2026-05-06 pre-launch CLI hardening pass. Each ``test_*`` here pins down one of the audit-discovered behaviours: * ``set_base_url`` rejects credentials, paths, whitespace, and ``http://`` to non-loopback hosts. * ``set_token`` rejects embedded whitespace and impossibly-short PATs. * ``_load_file`` writes a stderr breadcrumb on JSON corruption. * ``_atomic_write`` does NOT chmod a non-helix-owned parent dir. * ``_extract_text_block`` does NOT leak the JSON-RPC envelope when a tool returns an empty result. * ``emit_json`` doesn't crash on circular refs. """ from __future__ import annotations import json import os from pathlib import Path import pytest from helix_cli import config as cli_config from helix_cli.mcp_client import _extract_text_block from helix_cli.output import emit_json # ── set_base_url ────────────────────────────────────────────────────── def test_set_base_url_rejects_userinfo(cli_env: Path) -> None: with pytest.raises(ValueError, match="credentials"): cli_config.set_base_url("https://user:pass@helix.example.com") def test_set_base_url_rejects_path_beyond_root(cli_env: Path) -> None: with pytest.raises(ValueError, match="host root"): cli_config.set_base_url("https://helix.example.com/api/v1/mcp/") def test_set_base_url_rejects_internal_whitespace(cli_env: Path) -> None: with pytest.raises(ValueError, match="whitespace"): cli_config.set_base_url("https://helix .example.com") def test_set_base_url_rejects_query_string(cli_env: Path) -> None: with pytest.raises(ValueError, match="query"): cli_config.set_base_url("https://helix.example.com?token=helix_pat_x") def test_set_base_url_rejects_http_remote(cli_env: Path) -> None: with pytest.raises(ValueError, match="loopback"): cli_config.set_base_url("http://helix.example.com") def test_set_base_url_allows_http_localhost(cli_env: Path) -> None: p = cli_config.set_base_url("http://localhost:8000") assert json.loads(Path(p).read_text())["base_url"] == "http://localhost:8000" def test_set_base_url_allows_http_loopback_v4(cli_env: Path) -> None: p = cli_config.set_base_url("http://127.0.0.1:8000/") # Trailing slash stripped on the way in. assert json.loads(Path(p).read_text())["base_url"] == "http://127.0.0.1:8000" def test_set_base_url_allows_https_remote(cli_env: Path) -> None: cli_config.set_base_url("https://helix.example.com") # ── set_token ───────────────────────────────────────────────────────── def test_set_token_rejects_embedded_whitespace(cli_env: Path) -> None: bad = "helix_pat_" + "x" * 16 + "\n" + "y" * 8 with pytest.raises(ValueError, match="whitespace"): cli_config.set_token(bad) def test_set_token_rejects_short_token(cli_env: Path) -> None: with pytest.raises(ValueError, match="suspiciously short"): cli_config.set_token("helix_pat_abc") def test_set_token_accepts_realistic_token(cli_env: Path) -> None: real = "helix_pat_" + "a" * 32 # 42 chars total, matches generator output p = cli_config.set_token(real) assert json.loads(Path(p).read_text())["token"] == real # ── _load_file warning on corruption ───────────────────────────────── def test_load_file_warns_on_corruption( cli_env: Path, capsys: pytest.CaptureFixture, ) -> None: cli_env.write_text("{ this is not valid json", encoding="utf-8") out = cli_config._load_file(cli_env) # noqa: SLF001 assert out == {} err = capsys.readouterr().err assert "config file" in err and "corrupted" in err # ── parent-dir chmod is helix-only ─────────────────────────────────── def test_atomic_write_skips_chmod_on_foreign_parent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """A user-supplied HELIX_CONFIG path under e.g. /tmp must NOT cause the CLI to chmod /tmp itself. """ # Set the parent to a known-wider mode and verify the CLI does NOT # tighten it after writing. foreign_dir = tmp_path / "foreign" foreign_dir.mkdir(mode=0o755) cfg = foreign_dir / "config.json" monkeypatch.setenv("HELIX_CONFIG", str(cfg)) monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) cli_config.set_token("helix_pat_" + "z" * 32) # File itself should be 0o600. file_mode = os.stat(cfg).st_mode & 0o777 assert file_mode == 0o600 # Foreign parent should stay at 0o755 (not flipped to 0o700). parent_mode = os.stat(foreign_dir).st_mode & 0o777 assert parent_mode == 0o755 def test_atomic_write_chmods_helix_owned_parent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """When the parent IS the helix-owned dir, we should still tighten it to 0o700 so a world-readable XDG_CONFIG_HOME doesn't leak the file's existence. """ xdg = tmp_path / "xdg" xdg.mkdir(mode=0o755) monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) monkeypatch.delenv("HELIX_CONFIG", raising=False) cli_config.set_token("helix_pat_" + "y" * 32) helix_dir = xdg / "helix" assert helix_dir.is_dir() parent_mode = os.stat(helix_dir).st_mode & 0o777 assert parent_mode == 0o700 # ── _extract_text_block doesn't leak envelope ──────────────────────── def test_extract_text_block_returns_none_on_empty_envelope() -> None: """A tool with no content / no structuredContent must NOT cause the CLI to print the JSON-RPC ``result`` envelope (with isError / _meta / future-protocol keys) to the operator. """ envelope = {"isError": False, "_meta": {"some": "internal"}} assert _extract_text_block(envelope) is None def test_extract_text_block_prefers_structured_content() -> None: envelope = {"structuredContent": {"items": [1, 2]}} assert _extract_text_block(envelope) == {"items": [1, 2]} def test_extract_text_block_decodes_text_block() -> None: envelope = {"content": [{"type": "text", "text": '{"ok": true}'}]} assert _extract_text_block(envelope) == {"ok": True} # ── emit_json soft-fails on circular refs ──────────────────────────── def test_emit_json_handles_circular( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, ) -> None: a: dict = {} a["self"] = a # circular # Should not raise. emit_json(a) captured = capsys.readouterr() assert "__unserializable__" in captured.out # ── CliConfig.__repr__ PAT redaction (2026-05-16 fix-batch) ───────────── def test_cliconfig_repr_redacts_token() -> None: """``CliConfig.__repr__`` must not leak the PAT body. The dataclass-generated ``__repr__`` would otherwise emit the full token verbatim and a future ``print(cfg)``, third-party traceback locals dump, or pytest failure capture would land the PAT in scrollback / CI logs. """ full_pat = "helix_pat_" + "a" * 32 # 42 chars total, real shape cfg = cli_config.CliConfig( base_url="https://prod.example.com", token=full_pat, base_url_source="file", token_source="file", ) rendered = repr(cfg) # Prefix may appear (it's a fixed literal, not a secret). assert full_pat not in rendered assert "a" * 16 not in rendered # no fragment of the body # The redacted form should be present. assert "helix_pat_aa****" in rendered def test_cliconfig_repr_handles_unset_token() -> None: cfg = cli_config.CliConfig( base_url="https://prod", token=None, base_url_source="default", token_source="", ) # Must not raise; must not invent a token. ``redact_pat(None)`` is # defined to return the literal "" — verify that explicit # marker so a future refactor that swaps it for an empty string or # the bare word "None" fails loudly. rendered = repr(cfg) assert "" in rendered # ── Terminal-escape strip on output redaction (2026-05-16) ──────────── def test_output_redact_strips_ansi_and_bidi() -> None: """``redact()`` must drop C0 controls + bidi overrides so a server- returned asset note can't rewrite the user's terminal view. """ from helix_cli.output import redact cleared = redact("hello\x1b[2Jworld") assert "\x1b" not in cleared # ESC stripped; the surrounding visible chars remain (the strip # removes only the control byte, not the bracket bytes — but the # user can no longer trigger a screen-clear). assert "hello" in cleared and "world" in cleared # Bidi overrides also stripped. bidi = redact("attack‮txt.exe") assert "‮" not in bidi # Tab and newline preserved (legitimate output). preserved = redact("line1\nline2\tcol2") assert "\n" in preserved and "\t" in preserved def test_output_redact_strips_c1_controls() -> None: """C1 control codes (U+0080–U+009F) must be stripped too. xterm-class terminals honour C1 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 terminal just like the C0 ESC sequences the table already strips. """ from helix_cli.output import redact # One-char CSI (U+009B) + OSC (U+009D) terminated by ST # (U+009C), the worst offenders. cleared = redact("hello\u009b2Jworld\u009d0;evil\u009c") assert "\u009b" not in cleared assert "\u009d" not in cleared assert "\u009c" not in cleared assert "hello" in cleared and "world" in cleared # The entire C1 range, exhaustively. for cp in range(0x80, 0xA0): assert chr(cp) not in redact(f"x{chr(cp)}y"), hex(cp) # C0 / bidi entries still stripped (the C1 extension must not have # replaced the existing table). assert "\x1b" not in redact("a\x1b[2Jb") assert "‮" not in redact("a‮b") # ── Destructive-confirm bypass (2026-05-16) ─────────────────────────── def test_destructive_set_includes_user_initiated_transfers() -> None: """Regression for round-2 review: the destructive-tool list must cover initiator-cancel AND recipient-reject of an asset transfer, not just the admin-cancel variant. """ from helix_cli.main import _DESTRUCTIVE_MCP_TOOLS assert "cancel_asset_transfer" in _DESTRUCTIVE_MCP_TOOLS assert "reject_asset_transfer" in _DESTRUCTIVE_MCP_TOOLS assert "admin_cancel_asset_transfer" in _DESTRUCTIVE_MCP_TOOLS # And the spot-check destructive write paths. assert "revoke_api_key" in _DESTRUCTIVE_MCP_TOOLS assert "delete_sso_app" in _DESTRUCTIVE_MCP_TOOLS assert "reveal_api_key" in _DESTRUCTIVE_MCP_TOOLS def test_destructive_set_covers_launch_audit_additions() -> None: """2026-06 launch audit: the TTY-confirm set was missing several high-blast-radius tools (server destroy, provisioning execution, 4-eyes bypass, ...). Spot-check the worst of them. """ from helix_cli.main import _DESTRUCTIVE_MCP_TOOLS for tool in ( "server_destroy", "leaver_blocked_force_bypass_grace", "bypass_4_eyes_dns_request", "execute_provisioning_task", "apply_provisioning_bundle_to_person", "mark_person_leaver_manual", ): assert tool in _DESTRUCTIVE_MCP_TOOLS, tool def test_every_destructive_tool_name_is_a_registered_mcp_tool() -> None: """Typo-proof the destructive set forever. The set's docstring warns that names use the literal registered tool name and that "a typo here fails silently to 'no confirmation needed'". This test makes that failure loud: every entry must appear as a ``name="..."`` registration in ``server/app/mcp/tools.py``. We scan the SOURCE with a regex instead of importing ``app.mcp.tools`` — importing it pulls in the whole app stack (FastAPI / SQLAlchemy / domain services), which the CLI test suite deliberately avoids. The CLI is its own distribution (only depends on httpx), so the backend source lives in the sibling ``server/`` tree. When this package is checked out on its own (no backend), the cross-package invariant simply can't be checked — skip rather than fail, so ``cd cli && pytest`` stays green in isolation while CI (full-repo checkout) still enforces it. """ import re import pytest from helix_cli.main import _DESTRUCTIVE_MCP_TOOLS tools_src = ( Path(__file__).resolve().parents[2] / "server" / "app" / "mcp" / "tools.py" ) if not tools_src.exists(): pytest.skip("backend source (server/app/mcp/tools.py) not present") registered = set( re.findall(r'name="([^"]+)"', tools_src.read_text(encoding="utf-8")) ) missing = sorted(_DESTRUCTIVE_MCP_TOOLS - registered) assert not missing, ( f"_DESTRUCTIVE_MCP_TOOLS entries not registered in " f"server/app/mcp/tools.py (typo? renamed tool?): {missing}" )