From bc036247ab73f4c398da0f4b00a1b3d8befe76d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:35:33 +0000 Subject: [PATCH] improve: fix security issues, critical bug, add /agent command, enhance reliability Agent-Logs-Url: https://github.com/Rahulchaube1/QGo/sessions/289d04ce-df2e-4fb9-9951-2920afa8b14e Co-authored-by: Rahulchaube1 <157899057+Rahulchaube1@users.noreply.github.com> --- qgo/coders/base_coder.py | 25 +++++++++++++++ qgo/llm/litellm_provider.py | 4 +++ qgo/ui/commands.py | 64 +++++++++++++++++++++++++++++++++++-- qgo/ui/terminal.py | 2 ++ qgo/utils/web_scraper.py | 33 ++++++++++++++++++- 5 files changed, 124 insertions(+), 4 deletions(-) diff --git a/qgo/coders/base_coder.py b/qgo/coders/base_coder.py index 6c2b35f..c7de5e9 100644 --- a/qgo/coders/base_coder.py +++ b/qgo/coders/base_coder.py @@ -205,6 +205,31 @@ def run(self, user_message: str) -> str: def _send(self, messages: list[dict]) -> str: """Send messages to the LLM and return the complete response.""" + # Warn if the estimated token count approaches the context window + try: + def _content_text(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + return " ".join( + p.get("text", "") for p in content if isinstance(p, dict) + ) + return "" + + estimated = sum( + self.llm.count_tokens(_content_text(m.get("content"))) + for m in messages + ) + limit = self.llm.context_window + if estimated > limit * 0.9: + self._warn( + f"⚠️ Context is ~{estimated:,} tokens, close to the " + f"{limit:,}-token limit for {self.llm.model_name}. " + "Consider using /drop to remove files or /clear to reset history." + ) + except Exception: + pass # Token estimation is best-effort; never block the request + stream = self.config.stream if stream and self.io: diff --git a/qgo/llm/litellm_provider.py b/qgo/llm/litellm_provider.py index 17f50d9..359a6aa 100644 --- a/qgo/llm/litellm_provider.py +++ b/qgo/llm/litellm_provider.py @@ -153,6 +153,10 @@ def _call_with_retry(self, params: dict): wait = 2 ** attempt time.sleep(wait) last_error = e + except litellm.exceptions.Timeout as e: + wait = 2 ** attempt + time.sleep(wait) + last_error = e except Exception as e: raise e diff --git a/qgo/ui/commands.py b/qgo/ui/commands.py index a2dca7e..a614d98 100644 --- a/qgo/ui/commands.py +++ b/qgo/ui/commands.py @@ -9,6 +9,7 @@ from __future__ import annotations import glob as glob_module +import shlex import subprocess from pathlib import Path from typing import TYPE_CHECKING @@ -62,6 +63,7 @@ def handle(self, text: str) -> bool: "/help": self._cmd_help, "/exit": self._cmd_exit, "/quit": self._cmd_exit, + "/agent": self._cmd_agent, }.get(cmd) if handler: @@ -175,8 +177,9 @@ def _cmd_run(self, args: str) -> None: self.io.print_warning("Usage: /run ") return try: + cmd = shlex.split(args) result = subprocess.run( - args, shell=True, capture_output=True, text=True, timeout=60 + cmd, shell=False, capture_output=True, text=True, timeout=60 ) output = (result.stdout + result.stderr).strip() if output: @@ -280,10 +283,10 @@ def _cmd_git(self, args: str) -> None: if not args: self.io.print_warning("Usage: /git ") return - cmd = f"git {args}" try: + cmd = ["git"] + shlex.split(args) result = subprocess.run( - cmd, shell=True, capture_output=True, text=True, timeout=30 + cmd, shell=False, capture_output=True, text=True, timeout=30 ) output = (result.stdout + result.stderr).strip() if output: @@ -325,6 +328,61 @@ def _cmd_ls(self, args: str) -> None: except PermissionError: self.io.print_error(f"Permission denied: {path}") + def _cmd_agent(self, args: str) -> None: + """Run the multi-agent pipeline (or a single named agent). + + Usage: + /agent + /agent --agent coder + """ + if not args: + self.io.print_warning( + "Usage: /agent [--agent ] \n" + "Available agents: planner, coder, reviewer, tester, " + "debugger, doc_writer, security, refactor" + ) + return + + # Parse optional --agent flag + agent_name: str | None = None + task = args + parts = args.split(maxsplit=2) + if len(parts) >= 2 and parts[0] == "--agent": + agent_name = parts[1] + task = parts[2] if len(parts) > 2 else "" + if not task: + self.io.print_warning("Please provide a task description after --agent .") + return + + try: + from qgo.agents.orchestrator import AgentOrchestrator + + files = { + str(fc.path): fc.content + for fc in self.coder.chat_files + } + orchestrator = AgentOrchestrator( + llm=self.coder.llm, + config=self.coder.config, + io=self.io, + ) + if agent_name: + result = orchestrator.run_single(agent_name, task, context={"files": files}) + report = result.output if result.success else f"Agent error: {result.error}" + else: + report = orchestrator.run(task, files=files) + + self.io.print_assistant(report) + # Add the report to conversation context + self.coder.messages.append({ + "role": "assistant", + "content": report, + }) + except ValueError as exc: + self.io.print_error(str(exc)) + except Exception as exc: + self.io.print_error(f"Multi-agent pipeline error: {exc}") + def _cmd_config(self, args: str) -> None: self.io.print_info(str(self.coder.config)) diff --git a/qgo/ui/terminal.py b/qgo/ui/terminal.py index 76d5fb3..36f8413 100644 --- a/qgo/ui/terminal.py +++ b/qgo/ui/terminal.py @@ -296,6 +296,7 @@ def print_help(self) -> None: | `/paste` | Paste clipboard content | | `/ls [path]` | List directory files | | `/config` | Show current config | +| `/agent [--agent ] ` | Run multi-agent pipeline (or a specific agent) | | `/help` | Show this help | | `/exit` or `/quit` | Exit QGo | @@ -305,5 +306,6 @@ def print_help(self) -> None: - Press **Ctrl+D** to exit - Use `/add *.py` to add multiple files with glob patterns - Use `/image` before your question to send images to vision-capable models +- Use `/agent` to dispatch complex tasks to the full multi-agent pipeline """ self.console.print(Markdown(help_md)) diff --git a/qgo/utils/web_scraper.py b/qgo/utils/web_scraper.py index 11248ee..5046af7 100644 --- a/qgo/utils/web_scraper.py +++ b/qgo/utils/web_scraper.py @@ -24,6 +24,11 @@ def fetch_url(url: str, timeout: int = 15) -> str: Cleaned text content suitable for LLM context. Empty string if fetching fails. """ + try: + _validate_url(url) + except ValueError as exc: + return f"[{exc}]" + try: import requests from bs4 import BeautifulSoup @@ -95,6 +100,21 @@ def fetch_url(url: str, timeout: int = 15) -> str: return f"[Error fetching {url}: {exc}]" +def _validate_url(url: str) -> None: + """Raise ValueError if *url* uses a disallowed scheme. + + Only http and https are permitted to prevent SSRF via file://, ftp://, + gopher://, etc. + """ + import urllib.parse + + parsed = urllib.parse.urlparse(url) + if not parsed.scheme or parsed.scheme not in ("http", "https"): + raise ValueError( + f"Disallowed URL scheme {parsed.scheme!r}. Only http/https are permitted." + ) + + def fetch_page_info(url: str, timeout: int = 15) -> dict: """Fetch a web page and return structured info for browser-view display. @@ -109,6 +129,12 @@ def fetch_page_info(url: str, timeout: int = 15) -> dict: "links": [], "content": "", } + try: + _validate_url(url) + except ValueError as exc: + result["content"] = f"[{exc}]" + return result + try: import requests from bs4 import BeautifulSoup @@ -159,7 +185,7 @@ def fetch_page_info(url: str, timeout: int = 15) -> dict: return result - +def _fetch_plain(url: str, timeout: int = 15) -> str: """Minimal fallback using only urllib (no requests/bs4).""" try: import urllib.request @@ -179,6 +205,11 @@ def fetch_image_base64(url: str, timeout: int = 15) -> str | None: For use with vision-capable models. """ + try: + _validate_url(url) + except ValueError: + return None + try: import base64