"""Redaction filter — the security-critical chokepoint. Every byte the CLI prints passes through ``output.redact``. If a future patch removes the filter on a code path, these tests must catch it loudly. """ from __future__ import annotations import io import json import sys import httpx import pytest import respx from helix_cli import output from helix_cli.config import PAT_PREFIX from helix_cli.main import main FAKE_PAT = PAT_PREFIX + "abcdefghijklmnopqrstuvwxyz012345" BASE_URL = "https://helix.test.example" def test_pat_is_redacted_in_arbitrary_text() -> None: msg = f"oops here is my token {FAKE_PAT} please don't print" redacted = output.redact(msg) assert FAKE_PAT not in redacted assert f"{PAT_PREFIX}" in redacted def test_bearer_header_is_redacted_in_traceback_like_text() -> None: msg = "request headers: {'Authorization': 'Bearer abc.def.ghijklmnopqrs'}" redacted = output.redact(msg) assert "Bearer abc.def" not in redacted assert "" in redacted def test_jwt_is_redacted() -> None: jwt = "eyJabcdefghij.eyJabcdefghij.signaturevaluexyz" msg = f"jwt token leaked: {jwt}" redacted = output.redact(msg) assert jwt not in redacted assert "" in redacted def test_redacted_json_remains_parseable() -> None: """A redacted JSON payload must still parse as valid JSON. Regression test: the old substitution emitted ``"key": `` (no quotes) for quoted values, which broke ``--json`` consumers. Now we preserve the original quoting style so the output round- trips through ``json.loads``. """ import json as _json payload = _json.dumps({ "api_key": "sk-livesecretvalue123", "user": "alice", "nested": {"refresh_token": "1//abcdefghijk-l-mn-op_qr"}, }, indent=2) redacted = output.redact(payload) parsed = _json.loads(redacted) assert parsed["user"] == "alice" assert parsed["api_key"] == "" assert parsed["nested"]["refresh_token"] == "" # And the redacted string really doesn't contain the secret assert "sk-livesecretvalue123" not in redacted assert "1//abcdefghijk-l-mn-op_qr" not in redacted def test_secret_keys_are_redacted() -> None: cases = [ '"api_key": "sk-livesecretvalue123"', "password: hunter2hunter2", "client_secret = 'oauth-app-secret-do-not-leak'", '"refresh_token": "1//abcdefghijk-l-mn-op_qr-stu_vw_xy"', ] for original in cases: redacted = output.redact(original) # Secret value must be replaced; key must remain so the operator # knows what was masked. assert "" in redacted, original # The literal value substring (the obvious giveaway) must not # remain. for needle in ("hunter2hunter2", "sk-livesecretvalue123", "oauth-app-secret-do-not-leak", "1//abcdefghijk"): assert needle not in redacted, (original, needle, redacted) def test_redaction_runs_on_emit_error(monkeypatch: pytest.MonkeyPatch) -> None: buf = io.StringIO() monkeypatch.setattr(sys, "stderr", buf) output.emit_error(f"failed for token={FAKE_PAT}") text = buf.getvalue() assert FAKE_PAT not in text assert "" in text def test_pat_in_main_error_path_is_redacted( cli_env, capsys, monkeypatch ) -> None: """A failure that surfaces a PAT in the message must redact.""" monkeypatch.setenv("HELIX_PAT", FAKE_PAT) monkeypatch.setenv("HELIX_BASE_URL", "http://127.0.0.1:1") # closed port rc = main(["whoami"]) captured = capsys.readouterr() # Network error path: exit 5 assert rc == 5 # The redacted PAT prefix may surface in friendly errors but the # full body must never reach the terminal. assert FAKE_PAT not in captured.out assert FAKE_PAT not in captured.err # ── reveal-tool carve-out (launch audit 2026-06) ───────────────────── # # ``helix mcp call reveal_api_key`` exists to hand the user a # credential. The redact chokepoint contract is therefore: "every byte # except the result payload of an explicit reveal-tool invocation, # which still gets terminal-escape stripping." #: JWT-shaped — would normally hit ``_JWT_RE`` → "". JWT_SHAPED = "eyJ" + "a" * 12 + "." + "b" * 12 + "." + "c" * 12 def _mock_tool_payload(monkeypatch: pytest.MonkeyPatch, inner: dict) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) rpc = { "jsonrpc": "2.0", "id": "x", "result": { "isError": False, "content": [{"type": "text", "text": json.dumps(inner)}], }, } body = f"event: message\ndata: {json.dumps(rpc)}\n\n" respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response( 200, content=body.encode("utf-8"), headers={"content-type": "text/event-stream"}, ) ) @respx.mock def test_reveal_api_key_payload_is_printed_verbatim( cli_env, capsys, monkeypatch: pytest.MonkeyPatch ) -> None: """The revealed credential IS the answer — redacting it made the flagship reveal flow return ```` garbage with no hint. The key name ``api_key`` would also normally trip _SECRET_KEY_RE. """ _mock_tool_payload(monkeypatch, {"api_key": JWT_SHAPED}) rc = main(["--json", "mcp", "call", "reveal_api_key", "--yes"]) out = capsys.readouterr() assert rc == 0 assert JWT_SHAPED in out.out assert " None: """Same JWT-shaped value from any OTHER tool keeps full redaction — the carve-out is keyed on the invoked tool name, nothing else. """ _mock_tool_payload(monkeypatch, {"api_key": JWT_SHAPED}) rc = main(["--json", "mcp", "call", "get_my_api_keys"]) out = capsys.readouterr() assert rc == 0 assert JWT_SHAPED not in out.out assert " None: """Skipping secret redaction must NOT skip the terminal-control strip: a server-returned "credential" carrying ESC could otherwise rewrite the terminal. Human mode is the dangerous path (--json escapes control bytes during dumps anyway). """ _mock_tool_payload(monkeypatch, {"api_key": "abc\x1b[2Jdef"}) rc = main(["mcp", "call", "reveal_api_key", "--yes"]) out = capsys.readouterr() assert rc == 0 assert "\x1b" not in out.out assert "abc" in out.out and "def" in out.out def test_debug_mode_still_redacts( cli_env, capsys, monkeypatch ) -> None: """``HELIX_DEBUG=1`` enables tracebacks but must NOT disable redaction.""" monkeypatch.setenv("HELIX_DEBUG", "1") monkeypatch.setenv("HELIX_PAT", FAKE_PAT) monkeypatch.setenv("HELIX_BASE_URL", "http://127.0.0.1:1") rc = main(["whoami"]) captured = capsys.readouterr() assert rc != 0 assert FAKE_PAT not in captured.err assert FAKE_PAT not in captured.out