diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 0c22ae3b92300..209542745efce 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -5982,12 +5982,15 @@ class APIServerAdapter(BasePlatformAdapter): "timestamp": ts, } if preview is not None: - event["preview"] = preview + event["preview"] = redact_sensitive_text( + str(preview), force=True + ) for key in ( "goal", "task_count", "task_index", "subagent_id", + "child_session_id", "parent_id", "depth", "model", @@ -6005,8 +6008,16 @@ class APIServerAdapter(BasePlatformAdapter): "output_tail", ): value = kwargs.get(key) - if value is not None: - event[key] = value + if value is None: + continue + # Free-text fields can carry child terminal/tool output — + # force the same secret redaction the API applies to error + # text before it leaves the process on a public stream. + if key in ("goal", "summary", "output_tail") and isinstance( + value, str + ): + value = redact_sensitive_text(value, force=True) + event[key] = value _push(event) # _thinking, subagent.tool, and subagent_progress are intentionally # not forwarded on the /v1/runs stream: they are high-volume UI diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 37eb3f35f3433..4f6a4255fab03 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -941,6 +941,36 @@ class TestRunEventCallback: assert adapter._run_statuses[run_id]["last_event"] == "subagent.complete" + @pytest.mark.asyncio + async def test_subagent_events_redact_secrets_and_carry_child_session(self, adapter): + """Free-text fields (goal/summary/output_tail/preview) must pass the + forced secret redaction before hitting the public /v1/runs stream, + and child_session_id must survive the allowlist so clients can + correlate the child's session.""" + run_id = "run_subagent_redact" + loop = asyncio.get_running_loop() + queue = asyncio.Queue() + adapter._run_streams[run_id] = queue + adapter._run_statuses.pop(run_id, None) + + callback = adapter._make_run_event_callback(run_id, loop) + secret = "sk-proj-abcdef1234567890abcdef1234567890abcdef12" + callback( + "subagent.complete", + preview=f"leaked {secret}", + goal=f"use key {secret} to fetch data", + subagent_id="deleg_999", + child_session_id="child-sess-42", + status="completed", + summary=f"exported OPENAI_API_KEY={secret} then ran", + output_tail=f"env shows {secret}", + ) + + event = await asyncio.wait_for(queue.get(), timeout=1.0) + assert event["child_session_id"] == "child-sess-42" + for field in ("preview", "goal", "summary", "output_tail"): + assert secret not in event[field], field + # --------------------------------------------------------------------------- # /health endpoint