fix(gateway): redact credentials from TUI approval prompts (#48456)

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 <sunsky.lau@gmail.com>
This commit is contained in:
kshitijk4poor 2026-06-22 23:06:11 +05:30 committed by kshitij
parent 0e69cd4b37
commit c080b2dc3e
2 changed files with 84 additions and 3 deletions

View file

@ -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"
)

View file

@ -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