From c080b2dc3ee672251cce6de4d002632f4027f9f8 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:06:11 +0530 Subject: [PATCH] fix(gateway): redact credentials from TUI approval prompts (#48456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #50767, which redacted the chat-platform (_approval_notify_sync) and SSE/API (_approval_notify) approval transports. The TUI JSON-RPC transport is the third egress and was missed: three register_gateway_notify callbacks in tui_gateway/server.py emitted the raw approval_data — including the unredacted command Tirith flagged — straight to the TUI client via _emit. Route all three registrations through a new module-level _emit_approval_request() helper that redacts payload['command'] via the shared gateway.run._redact_approval_command seam before emitting, matching the pattern used for the other two transports. Completes the whole-bug-class fix for #48456. Tests: assert the helper emits a redacted command (real credential pattern), handles missing/None command, and a wiring guard that no registration emits the raw payload directly (only the helper may). Both mutation-checked. The #48456 fix series originated from @liuhao1024's #48462 — credit to them for the original report and chat-platform fix; this completes the remaining transport. Co-authored-by: liuhao1024 --- tests/gateway/test_tui_approval_redaction.py | 66 ++++++++++++++++++++ tui_gateway/server.py | 21 ++++++- 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_tui_approval_redaction.py 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