diff --git a/tests/gateway/test_tui_approval_redaction.py b/tests/gateway/test_tui_approval_redaction.py new file mode 100644 index 00000000000..04716222e78 --- /dev/null +++ b/tests/gateway/test_tui_approval_redaction.py @@ -0,0 +1,66 @@ +"""Regression test for TUI approval-prompt credential redaction (#48456). + +Follow-up to #50767, which redacted the chat-platform and SSE/API approval +transports. The TUI JSON-RPC transport is the third egress: three +`register_gateway_notify` callbacks in `tui_gateway/server.py` emit the raw +`approval_data` (with an unredacted `command`) to the TUI client. They now +route through the module-level `_emit_approval_request` helper, which redacts +`payload["command"]` via the shared `gateway.run._redact_approval_command` seam +before emitting. +""" + +import inspect + +import pytest + + +class TestTuiApprovalEmitRedaction: + def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch): + from tui_gateway import server as tui_server + + emitted = {} + monkeypatch.setattr( + tui_server, "_emit", + lambda event, sid, payload=None: emitted.update( + {"event": event, "sid": sid, "payload": payload} + ), + ) + raw = "curl -H 'Authorization: token ghp_01...6789' https://api.github.com" + tui_server._emit_approval_request("sess-1", {"command": raw, "description": "x"}) + + assert emitted["event"] == "approval.request" + # credential removed, non-command field + command structure preserved + assert "ghp_01...6789" not in emitted["payload"]["command"] + assert emitted["payload"]["description"] == "x" + assert "github.com" in emitted["payload"]["command"] + + def test_emit_approval_request_handles_missing_command(self, monkeypatch): + from tui_gateway import server as tui_server + + emitted = {} + monkeypatch.setattr( + tui_server, "_emit", + lambda event, sid, payload=None: emitted.update({"payload": payload}), + ) + tui_server._emit_approval_request("s", {"description": "no command here"}) + assert emitted["payload"] == {"description": "no command here"} + tui_server._emit_approval_request("s", None) + assert emitted["payload"] == {} + + def test_no_raw_command_emit_in_approval_registrations(self): + """Every register_gateway_notify approval callback must route through the + redacting `_emit_approval_request` helper — no registration may emit the + raw payload via `_emit("approval.request", ...)` directly. The ONLY + allowed raw emit is inside the helper itself.""" + from tui_gateway import server as tui_server + + src = inspect.getsource(tui_server) + raw_emits = src.count('_emit("approval.request"') + assert raw_emits == 1, ( + f'expected exactly 1 raw _emit("approval.request") (inside the ' + f"redacting helper), found {raw_emits} — a registration may be " + f"emitting the unredacted command" + ) + assert "_emit_approval_request(sid, data)" in src, ( + "registration lambdas must route through _emit_approval_request" + ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e8accfa8ba2..6bb4743dc9f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -806,6 +806,21 @@ def _emit(event: str, sid: str, payload: dict | None = None): write_json({"jsonrpc": "2.0", "method": "event", "params": params}) +def _emit_approval_request(sid: str, data: dict | None) -> None: + """Emit an ``approval.request`` event to the TUI client with the command + redacted. The approval payload is built from the RAW command string, so a + credential-shaped value Tirith flagged would otherwise be echoed verbatim + to the TUI client (#48456 — third egress transport alongside the chat + platforms and the SSE/API stream fixed in #50767). Reuse the shared gateway + seam so all approval transports redact consistently.""" + payload = dict(data or {}) + if "command" in payload: + from gateway.run import _redact_approval_command + + payload["command"] = _redact_approval_command(payload.get("command")) + _emit("approval.request", sid, payload) + + def _status_update(sid: str, kind: str, text: str | None = None): body = (text if text is not None else kind).strip() if not body: @@ -1040,7 +1055,7 @@ def _start_agent_build(sid: str, session: dict) -> None: ) register_gateway_notify( - key, lambda data: _emit("approval.request", sid, data) + key, lambda data: _emit_approval_request(sid, data) ) notify_registered = True load_permanent_allowlist() @@ -2554,7 +2569,7 @@ def _sync_session_key_after_compress( try: register_gateway_notify( new_session_id, - lambda data: _emit("approval.request", sid, data), + lambda data: _emit_approval_request(sid, data), ) except Exception: pass @@ -3916,7 +3931,7 @@ def _init_session( try: from tools.approval import register_gateway_notify, load_permanent_allowlist - register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) + register_gateway_notify(key, lambda data: _emit_approval_request(sid, data)) load_permanent_allowlist() except Exception: pass