diff --git a/gateway/run.py b/gateway/run.py index b2dea3d3c47..d685e9849aa 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6216,10 +6216,9 @@ class GatewayRunner: return None logger.debug("PRIORITY interrupt for session %s", _quick_key) running_agent.interrupt(event.text) - if _quick_key in self._pending_messages: - self._pending_messages[_quick_key] += "\n" + event.text - else: - self._pending_messages[_quick_key] = event.text + # NOTE: self._pending_messages was write-only (never consumed). + # The actual interrupt message is delivered via adapter._pending_messages + # which is read by _run_agent. Removed to prevent unbounded growth. return None # Check for commands @@ -6491,13 +6490,23 @@ class GatewayRunner: exec_cmd = qcmd.get("command", "") if exec_cmd: try: + # Sanitize env to prevent credential leakage — + # quick commands run in the gateway process which + # has all API keys in os.environ. + from tools.environments.local import _sanitize_subprocess_env + sanitized_env = _sanitize_subprocess_env(os.environ.copy()) proc = await asyncio.create_subprocess_shell( exec_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=sanitized_env, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) output = (stdout or stderr).decode().strip() + # Redact any remaining sensitive patterns in output + if output: + from agent.redact import redact_sensitive_text + output = redact_sensitive_text(output) return output if output else "Command returned no output." except asyncio.TimeoutError: return "Quick command timed out (30s)." diff --git a/tests/cli/test_quick_commands.py b/tests/cli/test_quick_commands.py index c89d639d13e..35903170bc0 100644 --- a/tests/cli/test_quick_commands.py +++ b/tests/cli/test_quick_commands.py @@ -1,4 +1,5 @@ """Tests for user-defined quick commands that bypass the agent loop.""" +import os import subprocess from unittest.mock import MagicMock, patch, AsyncMock from rich.text import Text @@ -159,6 +160,41 @@ class TestGatewayQuickCommands: result = await runner._handle_message(event) assert result == "ok" + @pytest.mark.asyncio + async def test_exec_command_does_not_leak_credentials(self): + """Quick command exec must sanitize env — API keys must not appear in output.""" + from gateway.run import GatewayRunner + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = {"quick_commands": {"leak": {"type": "exec", "command": "env"}}} + runner._running_agents = {} + runner._pending_messages = {} + runner._is_user_authorized = MagicMock(return_value=True) + + event = self._make_event("leak") + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-secret-12345"}): + result = await runner._handle_message(event) + + assert "sk-or-secret-12345" not in result, \ + "Quick command leaked OPENROUTER_API_KEY — exec runs without env sanitization" + + @pytest.mark.asyncio + async def test_exec_command_output_is_redacted(self): + """Quick command output must redact sensitive patterns before returning.""" + from gateway.run import GatewayRunner + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = {"quick_commands": {"token": {"type": "exec", "command": "echo sk-ant-api03-supersecretkey1234567890"}}} + runner._running_agents = {} + runner._pending_messages = {} + runner._is_user_authorized = MagicMock(return_value=True) + + event = self._make_event("token") + result = await runner._handle_message(event) + + assert "supersecretkey1234567890" not in result, \ + "Quick command output not redacted — raw API key returned to user" + @pytest.mark.asyncio async def test_unsupported_type_returns_error(self): from gateway.run import GatewayRunner