diff --git a/.github/praisonai-reviewer.yaml b/.github/praisonai-reviewer.yaml new file mode 100644 index 000000000..3cecb765e --- /dev/null +++ b/.github/praisonai-reviewer.yaml @@ -0,0 +1,11 @@ +framework: praisonai +topic: Pull Request Review +roles: + lead_reviewer: + role: Lead Code Reviewer + goal: Provide a comprehensive and constructive review of the pull request + backstory: You are an expert software engineer with deep knowledge of best practices, security, and performance. You meticulously review code for bugs and maintainability. + tasks: + code_review: + description: Review the code changes in the pull request. Look for logic errors, formatting issues, security flaws, and performance bottlenecks. Output the review as a detailed markdown comment. + expected_output: A detailed PR review in markdown format. diff --git a/.github/workflows/praisonai-pr-review.yml b/.github/workflows/praisonai-pr-review.yml new file mode 100644 index 000000000..056011c3a --- /dev/null +++ b/.github/workflows/praisonai-pr-review.yml @@ -0,0 +1,40 @@ +name: PraisonAI PR Reviewer + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + issue_comment: + types: [created] + +jobs: + review: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false && (github.event_name == 'pull_request' || contains(github.event.comment.body, '@praisonai')) + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate GitHub App Token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.PRAISONAI_APP_ID }} + private_key: ${{ secrets.PRAISONAI_APP_PRIVATE_KEY }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PraisonAI + run: pip install praisonaiagents[all] + + - name: Run PraisonAI PR Review + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + praisonai agents --file .github/praisonai-reviewer.yaml diff --git a/docker/Dockerfile.chat b/docker/Dockerfile.chat index 67ec4190a..0dd323074 100644 --- a/docker/Dockerfile.chat +++ b/docker/Dockerfile.chat @@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.5.126" \ + "praisonai>=4.5.127" \ "praisonai[chat]" \ "embedchain[github,youtube]" diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index c02dc7091..7faa4c328 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.5.126" \ + "praisonai>=4.5.127" \ "praisonai[ui]" \ "praisonai[chat]" \ "praisonai[realtime]" \ diff --git a/docker/Dockerfile.ui b/docker/Dockerfile.ui index a71ef9952..7337f3b97 100644 --- a/docker/Dockerfile.ui +++ b/docker/Dockerfile.ui @@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.5.126" \ + "praisonai>=4.5.127" \ "praisonai[ui]" \ "praisonai[crewai]" diff --git a/examples/yaml/PRAISONAI_PR_REVIEWER_SETUP.md b/examples/yaml/PRAISONAI_PR_REVIEWER_SETUP.md new file mode 100644 index 000000000..f140940aa --- /dev/null +++ b/examples/yaml/PRAISONAI_PR_REVIEWER_SETUP.md @@ -0,0 +1,25 @@ +# PraisonAI PR Reviewer Setup Guide + +This guide explains how to set up PraisonAI as an automated pull request reviewer using GitHub Actions and GitHub Apps. + +## Prerequisites +1. A GitHub App created within your organization or account. +2. The App must have the following permissions: + - Pull Requests: Read & Write + - Issues: Read & Write + - Contents: Read +3. Generate a Private Key for the GitHub App. + +## Setup Steps + +1. Configure GitHub Secrets for your repository: + - `PRAISONAI_APP_ID`: The App ID of your GitHub App. + - `PRAISONAI_APP_PRIVATE_KEY`: The generated Private Key (PEM format). + - `OPENAI_API_KEY` (or other LLM key) for PraisonAI to use. + +2. Ensure `.github/workflows/praisonai-pr-review.yml` is present in your default branch. + +3. Customize `.github/praisonai-reviewer.yaml` to configure the reviewing agents with specific roles. + +## Triggering the Review +The review will run automatically upon PR creation and synchronization. You can also trigger it manually by commenting `@praisonai` on any pull request or issue. diff --git a/examples/yaml/praisonai-pr-review.yml.template b/examples/yaml/praisonai-pr-review.yml.template new file mode 100644 index 000000000..9b935e281 --- /dev/null +++ b/examples/yaml/praisonai-pr-review.yml.template @@ -0,0 +1,41 @@ +name: PraisonAI PR Reviewer + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + issue_comment: + types: [created] + +jobs: + review: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false && (github.event_name == 'pull_request' || contains(github.event.comment.body, '@praisonai')) + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate GitHub App Token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.PRAISONAI_APP_ID }} + private_key: ${{ secrets.PRAISONAI_APP_PRIVATE_KEY }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PraisonAI + run: pip install praisonaiagents[all] + + - name: Run PraisonAI PR Review + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + # Use PraisonAI to review the PR + praisonai agents --file .github/praisonai-reviewer.yaml diff --git a/src/praisonai-agents/praisonaiagents/mcp/mcp.py b/src/praisonai-agents/praisonaiagents/mcp/mcp.py index 0d4e2ab35..e509c7053 100644 --- a/src/praisonai-agents/praisonaiagents/mcp/mcp.py +++ b/src/praisonai-agents/praisonaiagents/mcp/mcp.py @@ -329,6 +329,9 @@ def __init__(self, command_or_string=None, args=None, *, command=None, timeout=6 env = kwargs.get('env', {}) if not env: env = os.environ.copy() + # Sanitize environment to prevent leaking caller credentials + sensitive_pattern = re.compile(r'(?i)(api_key|token|secret|password|credential)') + env = {k: v for k, v in env.items() if not sensitive_pattern.search(k)} # Always set Python encoding env['PYTHONIOENCODING'] = 'utf-8' diff --git a/src/praisonai-agents/praisonaiagents/tools/file_tools.py b/src/praisonai-agents/praisonaiagents/tools/file_tools.py index 25bc2a124..756344110 100644 --- a/src/praisonai-agents/praisonaiagents/tools/file_tools.py +++ b/src/praisonai-agents/praisonaiagents/tools/file_tools.py @@ -126,13 +126,24 @@ def list_files(directory: str, pattern: Optional[str] = None) -> List[Dict[str, # Validate directory path safe_dir = FileTools._validate_path(directory) path = Path(safe_dir) + resolved_base = path.resolve() + if pattern: + if '..' in pattern: + raise ValueError("Pattern cannot contain '..'") + if Path(pattern).is_absolute() or pattern.startswith('/'): + raise ValueError("Pattern cannot be an absolute path") files = path.glob(pattern) else: files = path.iterdir() result = [] for file in files: + try: + file.resolve().relative_to(resolved_base) + except ValueError: + continue + if file.is_file(): stat = file.stat() result.append({ diff --git a/src/praisonai-agents/praisonaiagents/tools/shell_tools.py b/src/praisonai-agents/praisonaiagents/tools/shell_tools.py index 622353feb..c80afb759 100644 --- a/src/praisonai-agents/praisonaiagents/tools/shell_tools.py +++ b/src/praisonai-agents/praisonaiagents/tools/shell_tools.py @@ -59,14 +59,13 @@ def execute_command( else: command = shlex.split(command) - # Expand tilde and environment variables in command arguments + # Expand tilde in command arguments # (shell=False means the shell won't do this for us) - command = [os.path.expanduser(os.path.expandvars(arg)) for arg in command] + command = [os.path.expanduser(arg) for arg in command] # Expand tilde in cwd (subprocess doesn't do this) if cwd: cwd = os.path.expanduser(cwd) - cwd = os.path.expandvars(cwd) # Also expand $HOME, $USER, etc. if not os.path.isdir(cwd): # Fallback: try home directory, then current working directory fallback = os.path.expanduser("~") if os.path.isdir(os.path.expanduser("~")) else os.getcwd() diff --git a/src/praisonai-agents/praisonaiagents/tools/web_crawl_tools.py b/src/praisonai-agents/praisonaiagents/tools/web_crawl_tools.py index b36c7b1b3..5910172b1 100644 --- a/src/praisonai-agents/praisonaiagents/tools/web_crawl_tools.py +++ b/src/praisonai-agents/praisonaiagents/tools/web_crawl_tools.py @@ -139,14 +139,18 @@ def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]: # Try httpx first try: import httpx - with httpx.Client(follow_redirects=True, timeout=30.0) as client: + with httpx.Client(follow_redirects=False, timeout=30.0) as client: response = client.get(url) response.raise_for_status() content = response.text except ImportError: # Fallback to urllib import urllib.request - with urllib.request.urlopen(url, timeout=30) as response: + class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None # Disable redirects for security + opener = urllib.request.build_opener(NoRedirectHandler()) + with opener.open(url, timeout=30) as response: content = response.read().decode('utf-8', errors='ignore') # Basic HTML to text extraction diff --git a/src/praisonai-agents/praisonaiagents/ui/agui/agui.py b/src/praisonai-agents/praisonaiagents/ui/agui/agui.py index ae1e998a5..e9cf59011 100644 --- a/src/praisonai-agents/praisonaiagents/ui/agui/agui.py +++ b/src/praisonai-agents/praisonaiagents/ui/agui/agui.py @@ -117,13 +117,30 @@ def get_router(self) -> "APIRouter": def _attach_routes(self, router: "APIRouter") -> None: """Attach AG-UI routes to the router.""" + from fastapi import Request, HTTPException from fastapi.responses import StreamingResponse + import os encoder = EventEncoder() + def _check_auth(request: Request): + token = os.environ.get("AGUI_AUTH_TOKEN") or os.environ.get("PRAISONAI_AUTH_TOKEN") + if token: + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer ") or auth_header.split(" ")[1] != token: + raise HTTPException(status_code=401, detail="Unauthorized") + else: + client_host = request.client.host if request.client else "" + if client_host not in ("127.0.0.1", "::1", "localhost"): + raise HTTPException( + status_code=403, + detail="Access denied. Configure AGUI_AUTH_TOKEN for remote access." + ) + @router.post("/agui") - async def run_agent_agui(run_input: RunAgentInput): + async def run_agent_agui(request: Request, run_input: RunAgentInput): """Run the agent via AG-UI protocol.""" + _check_auth(request) async def event_generator(): async for event in self._run_agent(run_input): yield encoder.encode(event) @@ -134,15 +151,13 @@ async def event_generator(): headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "*", }, ) @router.get("/status") - async def get_status(): + async def get_status(request: Request): """Get agent status.""" + _check_auth(request) return {"status": "available"} async def _run_agent(self, run_input: RunAgentInput) -> AsyncIterator[BaseEvent]: diff --git a/src/praisonai-agents/pyproject.toml b/src/praisonai-agents/pyproject.toml index 332ca54f5..12fd7e760 100644 --- a/src/praisonai-agents/pyproject.toml +++ b/src/praisonai-agents/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonaiagents" -version = "1.5.126" +version = "1.5.127" description = "Praison AI agents for completing complex tasks with Self Reflection Agents" readme = "README.md" requires-python = ">=3.10" diff --git a/src/praisonai-agents/tests/unit/test_approval_protocol.py b/src/praisonai-agents/tests/unit/test_approval_protocol.py index 5b79e3937..ab092fa6b 100644 --- a/src/praisonai-agents/tests/unit/test_approval_protocol.py +++ b/src/praisonai-agents/tests/unit/test_approval_protocol.py @@ -206,16 +206,16 @@ def test_approve_async_with_auto_backend(self): def test_mark_and_check_approved(self): from praisonaiagents.approval.registry import ApprovalRegistry reg = ApprovalRegistry() - assert not reg.is_already_approved("execute_command") - reg.mark_approved("execute_command") - assert reg.is_already_approved("execute_command") + assert not reg.is_already_approved("write_file") + reg.mark_approved("write_file") + assert reg.is_already_approved("write_file") def test_already_approved_skips_backend(self): """Once approved, no backend call needed.""" from praisonaiagents.approval.registry import ApprovalRegistry reg = ApprovalRegistry() - reg.mark_approved("execute_command") - decision = reg.approve_sync("agent", "execute_command", {}) + reg.mark_approved("write_file") + decision = reg.approve_sync("agent", "write_file", {}) assert decision.approved is True assert "already" in decision.reason.lower() diff --git a/src/praisonai-agents/uv.lock b/src/praisonai-agents/uv.lock index 9859bcee6..685ef44f4 100644 --- a/src/praisonai-agents/uv.lock +++ b/src/praisonai-agents/uv.lock @@ -2951,7 +2951,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "1.5.126" +version = "1.5.127" source = { editable = "." } dependencies = [ { name = "aiohttp" }, diff --git a/src/praisonai/praisonai.rb b/src/praisonai/praisonai.rb index bcfb7d838..a78827fa8 100644 --- a/src/praisonai/praisonai.rb +++ b/src/praisonai/praisonai.rb @@ -3,8 +3,8 @@ class Praisonai < Formula desc "AI tools for various AI applications" homepage "https://github.com/MervinPraison/PraisonAI" - url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.126.tar.gz" - sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.126.tar.gz | shasum -a 256`.split.first + url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.127.tar.gz" + sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.127.tar.gz | shasum -a 256`.split.first license "MIT" depends_on "python@3.11" diff --git a/src/praisonai/praisonai/api/call.py b/src/praisonai/praisonai/api/call.py index b67fec60c..257e492ba 100644 --- a/src/praisonai/praisonai/api/call.py +++ b/src/praisonai/praisonai/api/call.py @@ -68,12 +68,12 @@ def import_tools_from_file(file_path): return custom_tools_module try: - if os.path.exists(tools_path): + if tools_path and os.path.exists(tools_path): # tools.py exists in the root directory, import from file custom_tools_module = import_tools_from_file(tools_path) logger.debug("Successfully imported custom tools from root tools.py") else: - logger.debug("No custom tools.py file found in the root directory") + logger.debug("No custom tools file found or specified") custom_tools_module = None if custom_tools_module: diff --git a/src/praisonai/praisonai/app/agentos.py b/src/praisonai/praisonai/app/agentos.py index d37323f02..15ff44fdf 100644 --- a/src/praisonai/praisonai/app/agentos.py +++ b/src/praisonai/praisonai/app/agentos.py @@ -117,8 +117,23 @@ def _create_app(self) -> Any: def _register_routes(self, app: Any) -> None: """Register API routes.""" - from fastapi import HTTPException + from fastapi import HTTPException, Request from pydantic import BaseModel + import os + + def _check_auth(request: Request): + token = os.environ.get("AGENTOS_AUTH_TOKEN") or os.environ.get("PRAISONAI_AUTH_TOKEN") + if token: + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer ") or auth_header.split(" ")[1] != token: + raise HTTPException(status_code=401, detail="Unauthorized") + else: + client_host = request.client.host if request.client else "" + if client_host not in ("127.0.0.1", "::1", "localhost"): + raise HTTPException( + status_code=403, + detail="Access denied. Configure AGENTOS_AUTH_TOKEN for remote access." + ) class ChatRequest(BaseModel): message: str @@ -145,7 +160,8 @@ async def health(): return {"status": "healthy"} @app.get(f"{self.config.api_prefix}/agents") - async def list_agents(): + async def list_agents(request: Request): + _check_auth(request) return { "agents": [ { @@ -160,16 +176,17 @@ async def list_agents(): } @app.post(f"{self.config.api_prefix}/chat", response_model=ChatResponse) - async def chat(request: ChatRequest): + async def chat(request_obj: ChatRequest, request: Request): + _check_auth(request) # Find the agent agent = None - if request.agent_name: + if request_obj.agent_name: for a in self.agents: - if getattr(a, 'name', None) == request.agent_name: + if getattr(a, 'name', None) == request_obj.agent_name: agent = a break if agent is None: - raise HTTPException(status_code=404, detail=f"Agent '{request.agent_name}' not found") + raise HTTPException(status_code=404, detail=f"Agent '{request_obj.agent_name}' not found") elif self.agents: agent = self.agents[0] else: @@ -177,7 +194,7 @@ async def chat(request: ChatRequest): # Call the agent try: - response = agent.chat(request.message) + response = agent.chat(request_obj.message) return ChatResponse( response=str(response), agent_name=getattr(agent, 'name', 'unknown'), diff --git a/src/praisonai/praisonai/cli/features/job_workflow.py b/src/praisonai/praisonai/cli/features/job_workflow.py index 644fb01dd..d0b7e4858 100644 --- a/src/praisonai/praisonai/cli/features/job_workflow.py +++ b/src/praisonai/praisonai/cli/features/job_workflow.py @@ -282,6 +282,33 @@ def _exec_python_script(self, script_path: str, step: Dict) -> Dict: def _exec_inline_python(self, code: str, step: Dict, flags: Dict) -> Dict: """Execute inline Python code in an isolated namespace.""" + import ast + + # Security validation (prevent sandbox escape) + try: + tree = ast.parse(code) + blocked_attrs = { + '__subclasses__', '__bases__', '__mro__', '__globals__', + '__code__', '__class__', '__dict__', '__builtins__', + '__import__', '__loader__', '__spec__', '__init_subclass__', + '__set_name__', '__reduce__', '__reduce_ex__', + '__traceback__', '__qualname__', '__module__', + } + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + return {"ok": False, "error": "Import statements are not allowed in script steps"} + if isinstance(node, ast.Attribute) and node.attr in blocked_attrs: + return {"ok": False, "error": f"Access to attribute '{node.attr}' is restricted"} + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id in ('exec', 'eval', 'compile', '__import__', 'open', 'input', 'breakpoint', 'setattr', 'getattr', 'delattr'): + return {"ok": False, "error": f"Call to '{node.func.id}' is not allowed"} + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if any(attr in node.value for attr in blocked_attrs): + return {"ok": False, "error": "String constant contains restricted attribute name"} + except SyntaxError as e: + return {"ok": False, "error": f"Syntax Error: {str(e)}"} + except Exception as e: + return {"ok": False, "error": f"Validation Error: {str(e)}"} _safe_builtins = { "True": True, "False": False, "None": None, "int": int, "float": float, "str": str, "bool": bool, diff --git a/src/praisonai/praisonai/deploy.py b/src/praisonai/praisonai/deploy.py index 20954b62d..95dbf04b0 100644 --- a/src/praisonai/praisonai/deploy.py +++ b/src/praisonai/praisonai/deploy.py @@ -57,7 +57,7 @@ def create_dockerfile(self): file.write("FROM python:3.11-slim\n") file.write("WORKDIR /app\n") file.write("COPY . .\n") - file.write("RUN pip install flask praisonai==4.5.126 gunicorn markdown\n") + file.write("RUN pip install flask praisonai==4.5.127 gunicorn markdown\n") file.write("EXPOSE 8080\n") file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n') diff --git a/src/praisonai/praisonai/gateway/server.py b/src/praisonai/praisonai/gateway/server.py index 9e6f8eeac..8d85e5149 100644 --- a/src/praisonai/praisonai/gateway/server.py +++ b/src/praisonai/praisonai/gateway/server.py @@ -243,7 +243,15 @@ async def health(request): def _check_auth(request) -> Optional[JSONResponse]: """Validate auth token if configured. Returns error response or None.""" if not self.config.auth_token: + # If no token is configured, restrict sensitive routes to localhost + client_ip = request.client.host if request.client else "127.0.0.1" + if client_ip not in ("127.0.0.1", "::1", "localhost"): + return JSONResponse( + {"error": "Authentication token not configured. Remote access denied."}, + status_code=403, + ) return None + auth_header = request.headers.get("authorization", "") if not auth_header.startswith("Bearer "): return JSONResponse( diff --git a/src/praisonai/praisonai/recipe/registry.py b/src/praisonai/praisonai/recipe/registry.py index 70f57f7c1..f989b7157 100644 --- a/src/praisonai/praisonai/recipe/registry.py +++ b/src/praisonai/praisonai/recipe/registry.py @@ -158,6 +158,11 @@ def _safe_extractall(tar: tarfile.TarFile, dest_dir: Path) -> None: raise RegistryError(f"Archive is too large uncompressed (>{MAX_SIZE} bytes)") member_path = Path(member.name) + # Protect against symlink attacks + if member.issym() or member.islnk(): + raise RegistryError( + f"Refusing to extract symlink/hardlink in archive: {member.name}" + ) # Reject absolute paths if member_path.is_absolute(): raise RegistryError( diff --git a/src/praisonai/praisonai/version.py b/src/praisonai/praisonai/version.py index 601df151a..0c73ec969 100644 --- a/src/praisonai/praisonai/version.py +++ b/src/praisonai/praisonai/version.py @@ -1 +1 @@ -__version__ = "4.5.126" \ No newline at end of file +__version__ = "4.5.127" \ No newline at end of file diff --git a/src/praisonai/pyproject.toml b/src/praisonai/pyproject.toml index 32ce6deb7..4ef33d753 100644 --- a/src/praisonai/pyproject.toml +++ b/src/praisonai/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "rich>=13.7", "markdown>=3.5", "pyparsing>=3.0.0", - "praisonaiagents>=1.5.126", + "praisonaiagents>=1.5.127", "python-dotenv>=0.19.0", "litellm>=1.81.0,<=1.82.6", "PyYAML>=6.0", diff --git a/src/praisonai/uv.lock b/src/praisonai/uv.lock index d3eb17f45..4c20fb7aa 100644 --- a/src/praisonai/uv.lock +++ b/src/praisonai/uv.lock @@ -5117,7 +5117,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "1.5.126" +version = "1.5.127" source = { directory = "../praisonai-agents" } dependencies = [ { name = "aiohttp" },