VYPR
High severityGHSA Advisory· Published May 29, 2026

PraisonAI vulnerable to unauthenticated arbitrary file read via MCP workflow.show, workflow.validate, deploy.validate

CVE-2026-47394

Description

Summary

The fix for GHSA-9mqq-jqxf-grvw / CVE-2026-44336 is incomplete. The original advisory description named four vulnerable handlers in mcp_server/adapters/cli_tools.py:

> "registers four file-handling tools by default, praisonai.rules.create, praisonai.rules.show, praisonai.rules.delete, **and praisonai.workflow.show**. Each accepts a path or filename string from MCP tools/call arguments… with no containment check."

Commit 68cc9427 ("fix(security): harden MCP rules path handling…") added a _resolve_rule_path() helper and applied it to rules.create, rules.show, and rules.delete. workflow.show was left unchanged. Two adjacent handlers in the same file have the same pattern, workflow.validate and deploy.validate. Neither was mentioned in the original advisory. Both remain unchanged.

The original advisory also identified the dispatcher (server.py:281-298) as a root cause. It accepts unvalidated **kwargs from params["arguments"] with no enforcement against the tool's declared input_schema. That code is unchanged in HEAD as of commit 42221210.

Result: A single unauthenticated MCP tools/call to praisonai.workflow.show returns the contents of any file the host user can read: /etc/passwd, ~/.ssh/id_rsa, ~/.aws/credentials, or any project .env.

Affected functionality

src/praisonai/praisonai/mcp_server/adapters/cli_tools.py:

| Lines | Tool | Bug | |-------|------|-----| | 63-73 | praisonai.workflow.show | Returns the full contents of any file the host user can read | | 42-61 | praisonai.workflow.validate | Reads any path; YAML parser error messages leak file existence + content fragments | | 415-432 | praisonai.deploy.validate | Same pattern as workflow.validate. The config_path="deploy.yaml" default does not constrain the input. |

src/praisonai/praisonai/mcp_server/server.py:281-298, _handle_tools_call:

async def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]:
    tool_name = params.get("name")
    arguments = params.get("arguments", {})
    ...
    tool = self._tool_registry.get(tool_name)
    ...
    if asyncio.iscoroutinefunction(tool.handler):
        result = await tool.handler(**arguments)        # ← no schema enforcement
    else:
        result = tool.handler(**arguments)

Any JSON arguments the MCP client sends become a **kwargs call to the handler. The original advisory pointed at this code path as the root cause. The May 3 patch did not change it.

Default deployment is exposed

src/praisonai/praisonai/mcp_server/transports/http_stream.py:38-91:

  • host defaults to 127.0.0.1, which is still reachable from any local process or container neighbour on loopback.
  • api_key defaults to None. The auth check at http_stream.py:192-198 is gated on if self.api_key:, so it is skipped when no key is configured. There is no env var or config switch that turns auth on by default.
  • The same handlers are also reachable on the stdio transport, which is the exploitation model the original advisory was written around (Claude Desktop, Cursor, Continue.dev, Claude Code).

Other file-read sinks reachable via the same dispatcher

These were not named in the original advisory. They confirm the bug is dispatcher-wide and not limited to cli_tools.py:

  • mcp_server/adapters/capabilities.py:19-28, praisonai.audio.transcribe(file_path). Opens any host file and ships it to OpenAI Whisper.
  • mcp_server/adapters/extended_capabilities.py:47-62, praisonai.files.create(file_path). Uploads any host file to OpenAI Files. A follow-up call to praisonai.files.content(file_id) (extended_capabilities.py:103-113) returns the bytes.
  • mcp_server/adapters/extended_capabilities.py:243-258, praisonai.ocr_extract(image_path). Opens any image, returns OCR text.

The three handlers in cli_tools.py are the most direct primitives, since they echo the file content back without an OpenAI round-trip.

Proof of

Concept

Layout

PraisonAI/
└── poc/
    ├── start_mcp_server.sh         ← starts the real MCP server
    ├── run_mcp_poc_video.sh        ← runs the attack with curl
    ├── venv/                       
    └── output/
        ├── mcp_server_run.log
        ├── mcp_attacker_run.log
        └── synthetic_credentials.txt   (PoC-only fake creds)

start_mcp_server.sh run_mcp_poc_video.sh

The server starter runs the real MCPServer class with register_cli_tools(), same code path praisonai mcp serve --transport http-stream uses. No mocks.

How to reproduce

Terminal 1, start the server: ``bash cd PraisonAI bash poc/start_mcp_server.sh ``

Boots MCPServer on 127.0.0.1:8766/mcp with no auth, matching the documented default api_key=None.

Terminal 2, run the attack: ``bash cd PraisonAI bash poc/run_mcp_poc_video.sh ``

Six numbered steps. Each one prints the action, runs one curl, prints the JSON-RPC response.

**workflow.validate leaks /etc/hosts:** ``json { "result": { "content": [{ "type": "text", "text": "YAML error: while scanning for the next token\nfound character '\\t' that cannot start any token\n in \"/etc/hosts\", line 7, column 10" }] } } ``

The parser error message confirms the file exists and includes a fragment of its content.

**deploy.validate leaks ~/.ssh/known_hosts:** ``json { "result": { "content": [{ "type": "text", "text": "Error: expected '', but found ''\n in \"/Users//.ssh/known_hosts\", line 1, column 13" }] } } ``

**workflow.show exfiltrates a credential file:** ``json { "result": { "content": [{ "type": "text", "text": "# AWS-style credentials (SYNTHETIC, for PoC only)\n[default]\naws_access_key_id = AKIA-FAKE-EXFIL-KEY-FOR-POC\naws_secret_access_key = synthetic-secret-do-not-actually-exist-12345\n\n# .env-style secrets\nDATABASE_URL=postgres://app:hunter2@db.internal/prod\nSLACK_BOT_TOKEN=xoxb-FAKE-TOKEN-for-poc-only\nOPENAI_API_KEY=sk-FAKE-FOR-POC\n" }] } } ``

The PoC writes its own synthetic credential file so the demonstration does not depend on the reviewer's real secrets. The same call reads ~/.ssh/id_rsa, ~/.aws/credentials, or any project .env if you point it there.

https://github.com/user-attachments/assets/09511e66-6a52-4fe3-a303-91d1f99cd27a

Impact

  • Confidentiality, High. Any file the praisonai user can read becomes available to the MCP caller. Typical targets are host SSH keys, cloud credentials, API tokens, project .env files, ~/.netrc, ~/.docker/config.json, browser cookie databases, and the system password file.
  • No authentication required. The default is api_key=None (http_stream.py:91). The auth check at http_stream.py:192-198 is wrapped in if self.api_key:, so it does not run when no key is configured.
  • No operator misconfiguration required. This is the documented default.
  • The original advisory's exploitation model still applies. An MCP-connected LLM whose context contains attacker-controlled web pages, documents, or emails can be steered into issuing the same tools/call and returning the response. No operator click is needed beyond "summarise this page".

The original advisory was Critical because the write primitive (rules.create) chained to RCE through .pth injection. This finding is the read half of the same shape. Read alone is enough to take SSH keys, cloud credentials, and tokens, which is usually how the rest of the host gets compromised through credential reuse.

Suggested fix

There are two ways to fix this. Doing both is fine. The dispatcher fix is preferred because it closes the same class of bug for every handler that takes a path-shaped argument, including the OpenAI-backed ones called out earlier.

1. Enforce tool.input_schema in the dispatcher

mcp_server/server.py:281-298. The schemas are already built reflectively from each handler's signature in registry.py:320-376. Validate arguments against the registered schema before calling tool.handler(**arguments) and reject anything that does not match. This covers workflow.show, workflow.validate, deploy.validate, audio.transcribe, files.create, ocr_extract, and any handler added later.

2. Per-handler containment

This is the same shape as the existing _resolve_rule_path() helper added in commit 68cc9427:

# cli_tools.py
def _resolve_workflow_path(file_path: str) -> Path:
    """Restrict workflow file_path to an allowed root."""
    if not isinstance(file_path, str) or not file_path:
        raise ValueError("file_path must be a non-empty string")
    if "\x00" in file_path or file_path.startswith("~"):
        raise ValueError(f"invalid file_path: {file_path!r}")
    workflows_root = Path(os.path.expanduser("~/.praison/workflows")).resolve()
    workflows_root.mkdir(parents=True, exist_ok=True)
    candidate = (workflows_root / file_path).resolve()
    try:
        candidate.relative_to(workflows_root)
    except ValueError:
        raise ValueError(f"invalid file_path: {file_path!r}")
    return candidate

Apply the same helper to:

  • workflow_show(file_path) and workflow_validate(file_path). Restrict to a workflow root.
  • deploy_validate(config_path). Restrict to a deploy-config root or an explicit allowlist.
  • The default="deploy.yaml" fallback resolves into the user's current working directory. Containment is what fixes the bug, but removing that default also makes prompt-injection chains harder.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Incomplete fix for CVE-2026-44336 leaves three MCP tools vulnerable to unauthenticated arbitrary file read.

Vulnerability

The fix for CVE-2026-44336 (GHSA-9mqq-jqxf-grvw) was incomplete. The original advisory identified four vulnerable handlers in mcp_server/adapters/cli_tools.py: praisonai.rules.create, praisonai.rules.show, praisonai.rules.delete, and praisonai.workflow.show. Commit 68cc9427 added a _resolve_rule_path() helper and applied it to the three rules.* tools, but left praisonai.workflow.show unchanged. Two additional handlers in the same file, praisonai.workflow.validate and praisonai.deploy.validate, exhibit the same pattern and were never mentioned in the original advisory. Furthermore, the dispatcher in server.py:281-298 (_handle_tools_call) accepts unvalidated **kwargs from params["arguments"] with no enforcement against the tool's declared input_schema, a root cause that remains unpatched as of commit 42221210 [1][2].

Exploitation

An unauthenticated attacker can send a single MCP tools/call request to any of the three vulnerable tools (praisonai.workflow.show, praisonai.workflow.validate, or praisonai.deploy.validate) with an arbitrary file path as an argument. No authentication or prior access is required. For workflow.show, the server returns the full contents of the specified file. For the validate tools, the YAML parser error messages leak file existence and content fragments [1][2].

Impact

Successful exploitation allows an attacker to read any file on the host filesystem that the MCP server process can read, including sensitive files such as /etc/passwd, ~/.ssh/id_rsa, ~/.aws/credentials, and project .env files. This results in a high confidentiality impact with no privileges gained beyond the host user's read access [1][2].

Mitigation

As of the advisory publication date (2026-05-29), no complete fix has been released. The incomplete patch in commit 68cc9427 did not address the three remaining handlers or the dispatcher root cause. Users should restrict network access to the MCP server to trusted clients only, or disable the affected tools (workflow.show, workflow.validate, deploy.validate) by removing or modifying the handler registrations in cli_tools.py until an official patch is available. This vulnerability is not listed on the CISA Known Exploited Vulnerabilities (KEV) catalog [1][2].

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
PraisonAIPyPI
< 4.6.404.6.40

Affected products

2
  • Praison/PraisonaiGHSA2 versions
    <= 4.6.39+ 1 more
    • (no CPE)range: <= 4.6.39
    • (no CPE)

Patches

4
179cab02dbec

refactor: harden input validation and access controls

https://github.com/MervinPraison/PraisonAIpraisonMay 19, 2026Fixed in 4.6.40via llm-release-walk
19 files changed · +425 100
  • .github/workflows/claude.yml+5 2 modified
    @@ -181,12 +181,15 @@ jobs:
     
           - name: Fetch PR branch and Setup Remote
             if: github.event.issue.pull_request
    +        env:
    +          PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
    +          IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
             run: |
               # Fetch PR head from base repo and put it into local branch
    -          git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }}
    +          git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
               
               # If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
    -          if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
    +          if [ "${IS_FORK}" = "true" ]; then
                 git remote set-url origin file://$(pwd)
               fi
     
    
  • src/praisonai-agents/praisonaiagents/tools/mentions.py+5 0 modified
    @@ -268,6 +268,11 @@ def _process_rule_mention(self, rule_name: str) -> Optional[str]:
         def _process_url_mention(self, url: str) -> Optional[str]:
             """Process @url:https://... mention."""
             try:
    +            from praisonaiagents.tools.spider_tools import SpiderTools
    +
    +            if not SpiderTools()._validate_url(url):
    +                return f"# URL: {url}\n[Blocked: URL is not allowed]"
    +
                 import urllib.request
                 
                 req = urllib.request.Request(
    
  • src/praisonai-agents/praisonaiagents/tools/python_tools.py+55 48 modified
    @@ -36,6 +36,30 @@ def _safe_getattr(obj, name, *default):
         return getattr(obj, name, *default) if default else getattr(obj, name)
     
     
    +_SANDBOX_BLOCKED_ATTRS = frozenset({
    +    '__subclasses__', '__bases__', '__mro__', '__globals__',
    +    '__code__', '__class__', '__dict__', '__builtins__',
    +    '__import__', '__loader__', '__spec__', '__init_subclass__',
    +    '__set_name__', '__reduce__', '__reduce_ex__',
    +    '__traceback__', '__qualname__', '__module__',
    +    '__wrapped__', '__closure__', '__annotations__',
    +    '__self__',  # C builtins leak real builtins module (GHSA-4mr5-g6f9-cfrh)
    +    # Frame/code object introspection
    +    'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
    +    'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
    +    'f_globals', 'f_locals', 'f_builtins', 'f_code',
    +    'co_consts', 'co_names',
    +    '__getattribute__', '__getattr__', '__setattr__', '__delattr__',
    +    '__dir__', '__get__', '__set__', '__delete__',
    +})
    +
    +_SANDBOX_BLOCKED_CALLS = frozenset({
    +    'exec', 'eval', 'compile', '__import__',
    +    'open', 'input', 'breakpoint',
    +    'setattr', 'delattr', 'dir', 'vars',
    +})
    +
    +
     def _validate_code_ast(code: str):
         """Validate code using AST — catches attacks that bypass text checks.
     
    @@ -48,52 +72,33 @@ def _validate_code_ast(code: str):
         except SyntaxError:
             return None  # let compile() handle syntax errors later
     
    -    # Dangerous dunder attributes attackers use for sandbox escape
    -    _blocked_attrs = frozenset({
    -        '__subclasses__', '__bases__', '__mro__', '__globals__',
    -        '__code__', '__class__', '__dict__', '__builtins__',
    -        '__import__', '__loader__', '__spec__', '__init_subclass__',
    -        '__set_name__', '__reduce__', '__reduce_ex__',
    -        '__traceback__', '__qualname__', '__module__',
    -        '__wrapped__', '__closure__', '__annotations__',
    -        # Frame/code object introspection
    -        'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
    -        'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
    -        'f_globals', 'f_locals', 'f_builtins', 'f_code',
    -        'co_consts', 'co_names',
    -        '__getattribute__', '__getattr__', '__setattr__', '__delattr__',
    -        '__dir__', '__get__', '__set__', '__delete__',
    -    })
    -
         for node in ast.walk(tree):
             # Block import statements
             if isinstance(node, (ast.Import, ast.ImportFrom)):
                 return f"Import statements are not allowed"
     
             # Block attribute access to dangerous dunders
             if isinstance(node, ast.Attribute):
    -            if node.attr in _blocked_attrs:
    +            if node.attr in _SANDBOX_BLOCKED_ATTRS:
                     return (
                         f"Access to attribute '{node.attr}' is restricted"
                     )
     
    -        # Block calls to dangerous builtins by name
    +        # Block calls to dangerous builtins (bare name or attribute access)
             if isinstance(node, ast.Call):
                 func = node.func
    -            if isinstance(func, ast.Name) and func.id in (
    -                'exec', 'eval', 'compile', '__import__',
    -                'open', 'input', 'breakpoint',
    -                'setattr', 'delattr', 'dir',
    -            ):
    +            if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS:
                     return f"Call to '{func.id}' is not allowed"
    +            if isinstance(func, ast.Attribute) and func.attr in _SANDBOX_BLOCKED_CALLS:
    +                return f"Call to '{func.attr}' is not allowed"
                     
             # Block dangerous constants (strings containing dunders)
             # Fallback for Python 3.7 ast.Str
             if isinstance(node, ast.Constant) and isinstance(node.value, str):
    -            if any(attr in node.value for attr in _blocked_attrs):
    +            if any(attr in node.value for attr in _SANDBOX_BLOCKED_ATTRS):
                     return f"String constant contains restricted attribute name"
             elif type(node).__name__ == 'Str':
    -            if any(attr in getattr(node, 's', '') for attr in _blocked_attrs):
    +            if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS):
                     return f"String constant contains restricted attribute name"
     
         return None
    @@ -112,7 +117,16 @@ def _execute_code_sandboxed(
         """
         if limits is None:
             limits = ResourceLimits.minimal()
    -    
    +
    +    ast_error = _validate_code_ast(code)
    +    if ast_error:
    +        return {
    +            'result': None,
    +            'stdout': '',
    +            'stderr': f'Security Error: {ast_error}',
    +            'success': False,
    +        }
    +
         try:
             # Create temporary file for the code
             with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
    @@ -151,21 +165,8 @@ def safe_execute():
                 }}
             
             # Block dangerous patterns
    -        blocked_attrs = {{
    -            '__subclasses__', '__bases__', '__mro__', '__globals__',
    -            '__code__', '__class__', '__dict__', '__builtins__',
    -            '__import__', '__loader__', '__spec__', '__init_subclass__',
    -            '__set_name__', '__reduce__', '__reduce_ex__',
    -            '__traceback__', '__qualname__', '__module__',
    -            '__wrapped__', '__closure__', '__annotations__',
    -            # Frame/code object introspection
    -            'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
    -            'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
    -            'f_globals', 'f_locals', 'f_builtins', 'f_code',
    -            'co_consts', 'co_names',
    -            '__getattribute__', '__getattr__', '__setattr__', '__delattr__',
    -            '__dir__', '__get__', '__set__', '__delete__',
    -        }}
    +        blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r}
    +        blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r}
             
             for node in ast.walk(tree):
                 if isinstance(node, (ast.Import, ast.ImportFrom)):
    @@ -182,14 +183,20 @@ def safe_execute():
                         "stderr": f"Access to attribute '{{node.attr}}' is restricted",
                         "success": False
                     }}
    -            if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
    -                if node.func.id in ('exec', 'eval', 'compile', '__import__',
    -                                     'open', 'input', 'breakpoint',
    -                                     'setattr', 'delattr', 'dir'):
    +            if isinstance(node, ast.Call):
    +                func = node.func
    +                if isinstance(func, ast.Name) and func.id in blocked_calls:
    +                    return {{
    +                        "result": None,
    +                        "stdout": "",
    +                        "stderr": f"Call to '{{func.id}}' is not allowed",
    +                        "success": False
    +                    }}
    +                if isinstance(func, ast.Attribute) and func.attr in blocked_calls:
                         return {{
                             "result": None,
                             "stdout": "",
    -                        "stderr": f"Call to '{{node.func.id}}' is not allowed",
    +                        "stderr": f"Call to '{{func.attr}}' is not allowed",
                             "success": False
                         }}
                 if isinstance(node, ast.Constant) and isinstance(node.value, str):
    @@ -481,7 +488,7 @@ def _execute_code_direct(
                 '__import__', 'import ', 'from ', 'exec', 'eval',
                 'compile', 'open(', 'file(', 'input(', 'raw_input',
                 '__subclasses__', '__bases__', '__globals__', '__code__',
    -            '__class__', 'globals(', 'locals(', 'vars('
    +            '__class__', '__self__', 'globals(', 'locals(', 'vars('
             ]
     
             code_lower = code.lower()
    
  • src/praisonai-agents/praisonaiagents/tools/spider_tools.py+46 24 modified
    @@ -11,6 +11,8 @@
     """
     
     import logging
    +import ipaddress
    +import socket
     from typing import List, Dict, Union, Optional, Any
     from importlib import util
     import json
    @@ -20,6 +22,48 @@
     import hashlib
     import time
     
    +
    +def _host_is_blocked(hostname: str) -> bool:
    +    """Return True when hostname resolves to loopback/private/internal targets."""
    +    if not hostname:
    +        return True
    +    host = hostname.lower().rstrip(".")
    +    if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"):
    +        return True
    +    if host in ("169.254.169.254", "metadata.google.internal"):
    +        return True
    +    if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")):
    +        return True
    +
    +    def _ip_blocked(ip: ipaddress._BaseAddress) -> bool:
    +        return bool(
    +            ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local
    +        )
    +
    +    if host.isdigit():
    +        try:
    +            return _ip_blocked(ipaddress.ip_address(int(host)))
    +        except (ValueError, OverflowError):
    +            return True
    +
    +    if host.startswith("0x"):
    +        try:
    +            return _ip_blocked(ipaddress.ip_address(int(host, 16)))
    +        except (ValueError, OverflowError):
    +            return True
    +
    +    try:
    +        return _ip_blocked(ipaddress.ip_address(host))
    +    except ValueError:
    +        pass
    +
    +    try:
    +        return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))
    +    except OSError:
    +        pass
    +
    +    return False
    +
     class SpiderTools:
         """Tools for web scraping and crawling."""
         
    @@ -59,31 +103,9 @@ def _validate_url(self, url: str) -> bool:
                 if not parsed.hostname:
                     return False
                 
    -            # Reject local/internal addresses
    -            hostname = parsed.hostname.lower()
    -            
    -            # Block localhost and loopback
    -            if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']:
    -                return False
    -            
    -            # Block private IP ranges
    -            import ipaddress
    -            try:
    -                ip = ipaddress.ip_address(hostname)
    -                if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
    -                    return False
    -            except ValueError:
    -                # Not an IP address, continue with domain validation
    -                pass
    -            
    -            # Block common internal domains
    -            if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):
    -                return False
    -            
    -            # Block metadata service endpoints
    -            if hostname in ['169.254.169.254', 'metadata.google.internal']:
    +            if _host_is_blocked(parsed.hostname):
                     return False
    -            
    +
                 return True
                 
             except Exception:
    
  • src/praisonai-agents/tests/unit/tools/test_mentions_url_ssrf.py+10 0 added
    @@ -0,0 +1,10 @@
    +"""@url mentions must not fetch loopback targets."""
    +
    +from praisonaiagents.tools.mentions import MentionsParser
    +
    +
    +def test_url_mention_blocks_loopback():
    +    parser = MentionsParser()
    +    result = parser._process_url_mention("http://127.0.0.1:8765/")
    +    assert result is not None
    +    assert "Blocked" in result
    
  • src/praisonai-agents/tests/unit/tools/test_python_tools_sandbox.py+16 0 modified
    @@ -112,6 +112,22 @@ def test_dunder_import_blocked(self, sandbox):
             result = sandbox.run("__import__('os')")
             assert result["success"] is False
     
    +    def test_print_self_blocked(self, sandbox):
    +        """print.__self__ leaks real builtins module (GHSA-4mr5-g6f9-cfrh)."""
    +        result = sandbox.run("b = print.__self__")
    +        assert result["success"] is False
    +        assert "restricted" in result["stderr"].lower()
    +
    +    def test_vars_call_blocked(self, sandbox):
    +        """vars() can expose builtins.__dict__ after __self__ leak."""
    +        result = sandbox.run("vars({})")
    +        assert result["success"] is False
    +
    +    def test_attribute_dunder_call_blocked(self, sandbox):
    +        """Attribute calls bypass bare-name Call checks."""
    +        result = sandbox.run("(1).__class__.__mro__")
    +        assert result["success"] is False
    +
     
     # ── Legitimate Code (all must PASS) ─────────────────────────────────────────
     
    
  • src/praisonai-agents/tests/unit/tools/test_spider_url_validation.py+10 0 modified
    @@ -38,6 +38,16 @@ def test_still_blocks_loopback():
         assert spider._validate_url("http://localhost/") is False
     
     
    +def test_blocks_alternate_loopback_encodings():
    +    """GHSA-5c6w-wwfq-7qqm: non-canonical loopback host forms."""
    +    spider = SpiderTools()
    +    assert spider._validate_url("http://localhost.:8765/") is False
    +    assert spider._validate_url("http://127.1:8765/") is False
    +    assert spider._validate_url("http://0177.0.0.1:8765/") is False
    +    assert spider._validate_url("http://0x7f000001:8765/") is False
    +    assert spider._validate_url("http://2130706433:8765/") is False
    +
    +
     def test_rejects_non_string_input():
         spider = SpiderTools()
         assert spider._validate_url(None) is False  # type: ignore[arg-type]
    
  • src/praisonai-platform/praisonai_platform/api/deps.py+36 0 modified
    @@ -71,3 +71,39 @@ async def require_workspace_member(
             )
         user.workspace_id = workspace_id
         return user
    +
    +
    +async def require_workspace_admin(
    +    workspace_id: str,
    +    user: AuthIdentity = Depends(get_current_user),
    +    session: AsyncSession = Depends(get_db),
    +) -> AuthIdentity:
    +    """Require admin or owner role in the workspace."""
    +    return await require_workspace_member(
    +        workspace_id, user, session, min_role="admin"
    +    )
    +
    +
    +async def require_workspace_owner(
    +    workspace_id: str,
    +    user: AuthIdentity = Depends(get_current_user),
    +    session: AsyncSession = Depends(get_db),
    +) -> AuthIdentity:
    +    """Require owner role in the workspace."""
    +    return await require_workspace_member(
    +        workspace_id, user, session, min_role="owner"
    +    )
    +
    +
    +def ensure_resource_in_workspace(
    +    resource_workspace_id: str | None,
    +    workspace_id: str,
    +    *,
    +    label: str = "Resource",
    +) -> None:
    +    """Reject cross-workspace access (IDOR) with a generic 404."""
    +    if resource_workspace_id != workspace_id:
    +        raise HTTPException(
    +            status_code=status.HTTP_404_NOT_FOUND,
    +            detail=f"{label} not found",
    +        )
    
  • src/praisonai-platform/praisonai_platform/api/routes/agents.py+7 1 modified
    @@ -9,7 +9,7 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import get_db, require_workspace_member
    +from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
     from ..schemas import AgentCreate, AgentResponse, AgentUpdate
     from ...services.agent_service import AgentService
     
    @@ -61,6 +61,7 @@ async def get_agent(
         agent = await svc.get(agent_id)
         if agent is None:
             raise HTTPException(status_code=404, detail="Agent not found")
    +    ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent")
         return AgentResponse.model_validate(agent)
     
     
    @@ -84,6 +85,7 @@ async def update_agent(
         )
         if agent is None:
             raise HTTPException(status_code=404, detail="Agent not found")
    +    ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent")
         return AgentResponse.model_validate(agent)
     
     
    @@ -95,6 +97,10 @@ async def delete_agent(
         session: AsyncSession = Depends(get_db),
     ):
         svc = AgentService(session)
    +    agent = await svc.get(agent_id)
    +    if agent is None:
    +        raise HTTPException(status_code=404, detail="Agent not found")
    +    ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent")
         deleted = await svc.delete(agent_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Agent not found")
    
  • src/praisonai-platform/praisonai_platform/api/routes/issues.py+17 1 modified
    @@ -9,7 +9,7 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import get_db, require_workspace_member
    +from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
     from ..schemas import (
         CommentCreate,
         CommentResponse,
    @@ -90,6 +90,7 @@ async def get_issue(
         issue = await svc.get(issue_id)
         if issue is None:
             raise HTTPException(status_code=404, detail="Issue not found")
    +    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
         return IssueResponse.model_validate(issue)
     
     
    @@ -114,6 +115,7 @@ async def update_issue(
         )
         if issue is None:
             raise HTTPException(status_code=404, detail="Issue not found")
    +    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
         act_svc = ActivityService(session)
         await act_svc.log(
             workspace_id, "issue.updated", "issue", issue.id,
    @@ -132,6 +134,10 @@ async def delete_issue(
         session: AsyncSession = Depends(get_db),
     ):
         svc = IssueService(session)
    +    issue = await svc.get(issue_id)
    +    if issue is None:
    +        raise HTTPException(status_code=404, detail="Issue not found")
    +    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
         deleted = await svc.delete(issue_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Issue not found")
    @@ -148,6 +154,11 @@ async def add_comment(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    issue_svc = IssueService(session)
    +    issue = await issue_svc.get(issue_id)
    +    if issue is None:
    +        raise HTTPException(status_code=404, detail="Issue not found")
    +    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
         svc = CommentService(session)
         comment = await svc.create(
             issue_id=issue_id,
    @@ -166,6 +177,11 @@ async def list_comments(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    issue_svc = IssueService(session)
    +    issue = await issue_svc.get(issue_id)
    +    if issue is None:
    +        raise HTTPException(status_code=404, detail="Issue not found")
    +    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
         svc = CommentService(session)
         comments = await svc.list_for_issue(issue_id)
         return [CommentResponse.model_validate(c) for c in comments]
    
  • src/praisonai-platform/praisonai_platform/api/routes/workspaces.py+55 6 modified
    @@ -9,7 +9,13 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import get_current_user, get_db, require_workspace_member
    +from ..deps import (
    +    get_current_user,
    +    get_db,
    +    require_workspace_admin,
    +    require_workspace_member,
    +    require_workspace_owner,
    +)
     from ..schemas import (
         MemberAdd,
         MemberResponse,
    @@ -64,7 +70,7 @@ async def get_workspace(
     async def update_workspace(
         workspace_id: str,
         body: WorkspaceUpdate,
    -    user: AuthIdentity = Depends(require_workspace_member),
    +    user: AuthIdentity = Depends(require_workspace_admin),
         session: AsyncSession = Depends(get_db),
     ):
         ws_svc = WorkspaceService(session)
    @@ -77,7 +83,7 @@ async def update_workspace(
     @router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
     async def delete_workspace(
         workspace_id: str,
    -    user: AuthIdentity = Depends(require_workspace_member),
    +    user: AuthIdentity = Depends(require_workspace_owner),
         session: AsyncSession = Depends(get_db),
     ):
         ws_svc = WorkspaceService(session)
    @@ -93,10 +99,16 @@ async def delete_workspace(
     async def add_member(
         workspace_id: str,
         body: MemberAdd,
    -    user: AuthIdentity = Depends(require_workspace_member),
    +    user: AuthIdentity = Depends(require_workspace_admin),
         session: AsyncSession = Depends(get_db),
     ):
         member_svc = MemberService(session)
    +    if body.role == "owner":
    +        if not await member_svc.has_role(workspace_id, user.id, "owner"):
    +            raise HTTPException(
    +                status_code=status.HTTP_403_FORBIDDEN,
    +                detail="Only owners can add another owner",
    +            )
         member = await member_svc.add(workspace_id, body.user_id, body.role)
         return MemberResponse.model_validate(member)
     
    @@ -117,10 +129,32 @@ async def update_member_role(
         workspace_id: str,
         user_id: str,
         body: MemberUpdate,
    -    user: AuthIdentity = Depends(require_workspace_member),
    +    user: AuthIdentity = Depends(require_workspace_admin),
         session: AsyncSession = Depends(get_db),
     ):
         member_svc = MemberService(session)
    +    target = await member_svc.get(workspace_id, user_id)
    +    if target is None:
    +        raise HTTPException(status_code=404, detail="Member not found")
    +    if user_id == user.id and body.role != target.role:
    +        raise HTTPException(
    +            status_code=status.HTTP_403_FORBIDDEN,
    +            detail="Cannot change your own role",
    +        )
    +    if body.role == "owner" and not await member_svc.has_role(
    +        workspace_id, user.id, "owner"
    +    ):
    +        raise HTTPException(
    +            status_code=status.HTTP_403_FORBIDDEN,
    +            detail="Only owners can assign the owner role",
    +        )
    +    if target.role == "owner" and not await member_svc.has_role(
    +        workspace_id, user.id, "owner"
    +    ):
    +        raise HTTPException(
    +            status_code=status.HTTP_403_FORBIDDEN,
    +            detail="Only owners can change an owner's role",
    +        )
         member = await member_svc.update_role(workspace_id, user_id, body.role)
         if member is None:
             raise HTTPException(status_code=404, detail="Member not found")
    @@ -131,10 +165,25 @@ async def update_member_role(
     async def remove_member(
         workspace_id: str,
         user_id: str,
    -    user: AuthIdentity = Depends(require_workspace_member),
    +    user: AuthIdentity = Depends(require_workspace_admin),
         session: AsyncSession = Depends(get_db),
     ):
         member_svc = MemberService(session)
    +    if user_id == user.id:
    +        raise HTTPException(
    +            status_code=status.HTTP_403_FORBIDDEN,
    +            detail="Cannot remove yourself from the workspace",
    +        )
    +    target = await member_svc.get(workspace_id, user_id)
    +    if target is None:
    +        raise HTTPException(status_code=404, detail="Member not found")
    +    if target.role == "owner" and not await member_svc.has_role(
    +        workspace_id, user.id, "owner"
    +    ):
    +        raise HTTPException(
    +            status_code=status.HTTP_403_FORBIDDEN,
    +            detail="Only owners can remove an owner",
    +        )
         removed = await member_svc.remove(workspace_id, user_id)
         if not removed:
             raise HTTPException(status_code=404, detail="Member not found")
    
  • src/praisonai-platform/praisonai_platform/services/auth_service.py+4 0 modified
    @@ -113,6 +113,10 @@ async def login(self, email: str, password: str) -> Optional[tuple[User, str]]:
     
         def _issue_token(self, user: User) -> str:
             """Issue a JWT for a user."""
    +        if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
    +            raise RuntimeError(
    +                "Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev"
    +            )
             now = datetime.now(timezone.utc)
             payload = {
                 "sub": user.id,
    
  • src/praisonai-platform/tests/test_workspace_rbac.py+18 0 added
    @@ -0,0 +1,18 @@
    +"""Workspace member RBAC and cross-workspace IDOR guards."""
    +
    +from __future__ import annotations
    +
    +import pytest
    +from fastapi import HTTPException
    +
    +from praisonai_platform.api.deps import ensure_resource_in_workspace
    +
    +
    +def test_ensure_resource_in_workspace_rejects_mismatch():
    +    with pytest.raises(HTTPException) as exc:
    +        ensure_resource_in_workspace("ws-a", "ws-b", label="Issue")
    +    assert exc.value.status_code == 404
    +
    +
    +def test_ensure_resource_in_workspace_allows_match():
    +    ensure_resource_in_workspace("ws-a", "ws-a", label="Issue")
    
  • src/praisonai/praisonai/api/agent_invoke.py+14 2 modified
    @@ -29,14 +29,26 @@
     # Authentication
     import os
     CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')
    +_CALL_AUTH_DISABLED = os.getenv('PRAISONAI_CALL_AUTH', '').lower() == 'disabled'
    +
     
     async def verify_token(
         request: Request, 
         authorization: Optional[str] = Header(None)
     ) -> None:
         """Verify API token for authentication."""
    -    if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:
    -        return  # No authentication if FastAPI unavailable or no token set
    +    if not FASTAPI_AVAILABLE:
    +        return
    +    if _CALL_AUTH_DISABLED:
    +        return
    +    if not CALL_SERVER_TOKEN:
    +        raise HTTPException(
    +            status_code=503,
    +            detail=(
    +                "CALL_SERVER_TOKEN is not configured. Set CALL_SERVER_TOKEN or "
    +                "PRAISONAI_CALL_AUTH=disabled to run without authentication."
    +            ),
    +        )
             
         token = None
         
    
  • src/praisonai/praisonai/code/tools/write_file.py+10 11 modified
    @@ -58,9 +58,10 @@ def write_file(
             >>> if result['success']:
             ...     print(f"Wrote to {result['path']}")
         """
    -    # Resolve path
    -    if workspace and not os.path.isabs(path):
    -        abs_path = os.path.abspath(os.path.join(workspace, path))
    +    # Resolve path — default workspace is cwd so relative paths cannot escape
    +    effective_workspace = workspace or os.getcwd()
    +    if not os.path.isabs(path):
    +        abs_path = os.path.abspath(os.path.join(effective_workspace, path))
         else:
             abs_path = os.path.abspath(path)
         
    @@ -73,14 +74,12 @@ def write_file(
                 'path': path,
             }
     
    -    # Security check - ensure path is within workspace if specified
    -    if workspace:
    -        if not is_path_within_directory(abs_path, workspace):
    -            return {
    -                'success': False,
    -                'error': f"Path '{path}' is outside the workspace",
    -                'path': path,
    -            }
    +    if not is_path_within_directory(abs_path, effective_workspace):
    +        return {
    +            'success': False,
    +            'error': f"Path '{path}' is outside the workspace",
    +            'path': path,
    +        }
         
         # Process content
         processed_content = content
    
  • src/praisonai/praisonai/mcp_server/adapters/cli_tools.py+33 5 modified
    @@ -21,6 +21,31 @@
     logger = logging.getLogger(__name__)
     
     
    +def _resolve_cwd_yaml_path(file_path: str) -> "Path":
    +    """Resolve a YAML path strictly inside the current working directory."""
    +    from pathlib import Path
    +
    +    if not isinstance(file_path, str) or not file_path:
    +        raise ValueError("file_path must be a non-empty string")
    +    if (
    +        "/" in file_path
    +        or "\\" in file_path
    +        or "\x00" in file_path
    +        or file_path.startswith(".")
    +        or file_path in ("..", ".")
    +    ):
    +        raise ValueError(f"invalid file_path: {file_path!r}")
    +    if not file_path.endswith((".yaml", ".yml")):
    +        raise ValueError("file_path must be a .yaml or .yml file")
    +    base = Path.cwd().resolve()
    +    candidate = (base / file_path).resolve()
    +    try:
    +        candidate.relative_to(base)
    +    except ValueError as exc:
    +        raise ValueError(f"invalid file_path: {file_path!r}") from exc
    +    return candidate
    +
    +
     def register_cli_tools() -> None:
         """Register CLI-based MCP tools."""
         
    @@ -44,7 +69,8 @@ def workflow_validate(file_path: str) -> str:
             """Validate a workflow YAML file."""
             try:
                 import yaml
    -            with open(file_path, 'r') as f:
    +            yaml_path = _resolve_cwd_yaml_path(file_path)
    +            with open(yaml_path, 'r') as f:
                     config = yaml.safe_load(f)
                 
                 required = ["framework", "topic"]
    @@ -64,9 +90,10 @@ def workflow_validate(file_path: str) -> str:
         def workflow_show(file_path: str) -> str:
             """Show workflow configuration."""
             try:
    -            with open(file_path, 'r') as f:
    -                content = f.read()
    -            return content
    +            yaml_path = _resolve_cwd_yaml_path(file_path)
    +            return yaml_path.read_text()
    +        except ValueError as e:
    +            return f"Error: {e}"
             except FileNotFoundError:
                 return f"File not found: {file_path}"
             except Exception as e:
    @@ -417,7 +444,8 @@ def deploy_validate(config_path: str = "deploy.yaml") -> str:
             """Validate deployment configuration."""
             try:
                 import yaml
    -            with open(config_path, 'r') as f:
    +            yaml_path = _resolve_cwd_yaml_path(config_path)
    +            with open(yaml_path, 'r') as f:
                     config = yaml.safe_load(f)
                 
                 required = ["name", "type"]
    
  • src/praisonai/tests/unit/code/test_write_file_workspace.py+23 0 added
    @@ -0,0 +1,23 @@
    +"""write_file must constrain paths when workspace is omitted."""
    +
    +from __future__ import annotations
    +
    +import os
    +
    +from praisonai.code.tools.write_file import write_file
    +
    +
    +def test_write_file_without_workspace_stays_in_cwd(tmp_path, monkeypatch):
    +    monkeypatch.chdir(tmp_path)
    +    ok = write_file("out.txt", "hello", workspace=None)
    +    assert ok["success"] is True
    +    assert (tmp_path / "out.txt").read_text() == "hello"
    +
    +
    +def test_write_file_without_workspace_blocks_escape(tmp_path, monkeypatch):
    +    project = tmp_path / "project"
    +    project.mkdir()
    +    monkeypatch.chdir(project)
    +    result = write_file("../outside.txt", "nope", workspace=None)
    +    assert result["success"] is False
    +    assert "outside" in result["error"].lower()
    
  • src/praisonai/tests/unit/mcp_server/test_cli_tools_path_hardening.py+21 0 added
    @@ -0,0 +1,21 @@
    +"""MCP CLI tools must not read arbitrary filesystem paths."""
    +
    +from __future__ import annotations
    +
    +import pytest
    +
    +
    +def test_resolve_cwd_yaml_rejects_traversal(tmp_path, monkeypatch):
    +    monkeypatch.chdir(tmp_path)
    +    from praisonai.mcp_server.adapters import cli_tools
    +
    +    (tmp_path / "workflow.yaml").write_text("framework: test\ntopic: t\n")
    +
    +    path = cli_tools._resolve_cwd_yaml_path("workflow.yaml")
    +    assert path.name == "workflow.yaml"
    +
    +    with pytest.raises(ValueError):
    +        cli_tools._resolve_cwd_yaml_path("../../etc/passwd")
    +
    +    with pytest.raises(ValueError):
    +        cli_tools._resolve_cwd_yaml_path("/etc/passwd")
    
  • src/praisonai/tests/unit/test_call_server_auth.py+40 0 added
    @@ -0,0 +1,40 @@
    +"""Call server agent API must not fail open without CALL_SERVER_TOKEN."""
    +
    +from __future__ import annotations
    +
    +import importlib
    +import os
    +
    +import pytest
    +
    +pytest.importorskip("fastapi")
    +
    +
    +@pytest.mark.asyncio
    +async def test_verify_token_requires_token_by_default(monkeypatch):
    +    monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False)
    +    monkeypatch.delenv("PRAISONAI_CALL_AUTH", raising=False)
    +
    +    mod = importlib.import_module("praisonai.api.agent_invoke")
    +    importlib.reload(mod)
    +
    +    class _Req:
    +        query_params = {}
    +
    +    with pytest.raises(Exception) as exc:
    +        await mod.verify_token(_Req(), authorization=None)
    +    assert "CALL_SERVER_TOKEN" in str(exc.value)
    +
    +
    +@pytest.mark.asyncio
    +async def test_verify_token_optout(monkeypatch):
    +    monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False)
    +    monkeypatch.setenv("PRAISONAI_CALL_AUTH", "disabled")
    +
    +    mod = importlib.import_module("praisonai.api.agent_invoke")
    +    importlib.reload(mod)
    +
    +    class _Req:
    +        query_params = {}
    +
    +    await mod.verify_token(_Req(), authorization=None)
    
516d8b6d88df

refactor: enforce workspace scope in platform services

https://github.com/MervinPraison/PraisonAIpraisonMay 19, 2026Fixed in 4.6.40via llm-release-walk
11 files changed · +120 52
  • SECURITY_TRIAGE.md+6 1 modified
    @@ -16,7 +16,12 @@
     | GHSA-6xj3-927j-6pqw | fixed batch 2 | deploy.py bleach sanitize |
     | GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True |
     | GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate |
    -| GHSA-gv23, h8q5, 6h6v, h37g | partial/defer | broader platform audit |
    +| GHSA-gv23, h8q5, 6h6v, h37g, 6h6v-7vxx | fixed batch 3 | service-layer workspace_id on get/update/delete |
    +| GHSA-h37g-4h4p-9x97, c2m8, 8g2p | fixed batch 3 | only owner assigns admin/owner |
    +| GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) |
    +| GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo |
     | GHSA-gmjg, 9q28 | published | prior release |
     
    +**Code fixed on main; GHSA state still triage until PyPI publish + advisory publish.**
    +
     Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
    
  • src/praisonai-platform/praisonai_platform/api/routes/agents.py+4 9 modified
    @@ -9,7 +9,7 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
    +from ..deps import get_db, require_workspace_member
     from ..schemas import AgentCreate, AgentResponse, AgentUpdate
     from ...services.agent_service import AgentService
     
    @@ -58,10 +58,9 @@ async def get_agent(
         session: AsyncSession = Depends(get_db),
     ):
         svc = AgentService(session)
    -    agent = await svc.get(agent_id)
    +    agent = await svc.get(agent_id, workspace_id=workspace_id)
         if agent is None:
             raise HTTPException(status_code=404, detail="Agent not found")
    -    ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent")
         return AgentResponse.model_validate(agent)
     
     
    @@ -76,6 +75,7 @@ async def update_agent(
         svc = AgentService(session)
         agent = await svc.update(
             agent_id,
    +        workspace_id=workspace_id,
             name=body.name,
             status=body.status,
             instructions=body.instructions,
    @@ -85,7 +85,6 @@ async def update_agent(
         )
         if agent is None:
             raise HTTPException(status_code=404, detail="Agent not found")
    -    ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent")
         return AgentResponse.model_validate(agent)
     
     
    @@ -97,10 +96,6 @@ async def delete_agent(
         session: AsyncSession = Depends(get_db),
     ):
         svc = AgentService(session)
    -    agent = await svc.get(agent_id)
    -    if agent is None:
    -        raise HTTPException(status_code=404, detail="Agent not found")
    -    ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent")
    -    deleted = await svc.delete(agent_id)
    +    deleted = await svc.delete(agent_id, workspace_id=workspace_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Agent not found")
    
  • src/praisonai-platform/praisonai_platform/api/routes/dependencies.py+2 0 modified
    @@ -62,6 +62,8 @@ async def delete_dependency(
         dep = await svc.get(dep_id)
         if dep is None:
             raise HTTPException(status_code=404, detail="Dependency not found")
    +    if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
    +        raise HTTPException(status_code=404, detail="Dependency not found")
         deleted = await svc.delete(dep_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Dependency not found")
    
  • src/praisonai-platform/praisonai_platform/api/routes/issues.py+3 8 modified
    @@ -87,10 +87,9 @@ async def get_issue(
         session: AsyncSession = Depends(get_db),
     ):
         svc = IssueService(session)
    -    issue = await svc.get(issue_id)
    +    issue = await svc.get(issue_id, workspace_id=workspace_id)
         if issue is None:
             raise HTTPException(status_code=404, detail="Issue not found")
    -    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
         return IssueResponse.model_validate(issue)
     
     
    @@ -105,6 +104,7 @@ async def update_issue(
         svc = IssueService(session)
         issue = await svc.update(
             issue_id,
    +        workspace_id=workspace_id,
             title=body.title,
             description=body.description,
             status=body.status,
    @@ -115,7 +115,6 @@ async def update_issue(
         )
         if issue is None:
             raise HTTPException(status_code=404, detail="Issue not found")
    -    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
         act_svc = ActivityService(session)
         await act_svc.log(
             workspace_id, "issue.updated", "issue", issue.id,
    @@ -134,11 +133,7 @@ async def delete_issue(
         session: AsyncSession = Depends(get_db),
     ):
         svc = IssueService(session)
    -    issue = await svc.get(issue_id)
    -    if issue is None:
    -        raise HTTPException(status_code=404, detail="Issue not found")
    -    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
    -    deleted = await svc.delete(issue_id)
    +    deleted = await svc.delete(issue_id, workspace_id=workspace_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Issue not found")
     
    
  • src/praisonai-platform/praisonai_platform/api/routes/projects.py+5 11 modified
    @@ -9,7 +9,7 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
    +from ..deps import get_db, require_workspace_member
     from ..schemas import ProjectCreate, ProjectResponse, ProjectUpdate
     from ...services.project_service import ProjectService
     
    @@ -56,10 +56,9 @@ async def get_project(
         session: AsyncSession = Depends(get_db),
     ):
         svc = ProjectService(session)
    -    project = await svc.get(project_id)
    +    project = await svc.get(project_id, workspace_id=workspace_id)
         if project is None:
             raise HTTPException(status_code=404, detail="Project not found")
    -    ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project")
         return ProjectResponse.model_validate(project)
     
     
    @@ -74,6 +73,7 @@ async def update_project(
         svc = ProjectService(session)
         project = await svc.update(
             project_id,
    +        workspace_id=workspace_id,
             title=body.title,
             description=body.description,
             status=body.status,
    @@ -82,7 +82,6 @@ async def update_project(
         )
         if project is None:
             raise HTTPException(status_code=404, detail="Project not found")
    -    ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project")
         return ProjectResponse.model_validate(project)
     
     
    @@ -94,11 +93,7 @@ async def delete_project(
         session: AsyncSession = Depends(get_db),
     ):
         svc = ProjectService(session)
    -    project = await svc.get(project_id)
    -    if project is None:
    -        raise HTTPException(status_code=404, detail="Project not found")
    -    ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project")
    -    deleted = await svc.delete(project_id)
    +    deleted = await svc.delete(project_id, workspace_id=workspace_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Project not found")
     
    @@ -111,8 +106,7 @@ async def project_stats(
         session: AsyncSession = Depends(get_db),
     ):
         svc = ProjectService(session)
    -    project = await svc.get(project_id)
    +    project = await svc.get(project_id, workspace_id=workspace_id)
         if project is None:
             raise HTTPException(status_code=404, detail="Project not found")
    -    ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project")
         return await svc.get_stats(project_id)
    
  • src/praisonai-platform/praisonai_platform/api/routes/workspaces.py+4 4 modified
    @@ -103,11 +103,11 @@ async def add_member(
         session: AsyncSession = Depends(get_db),
     ):
         member_svc = MemberService(session)
    -    if body.role == "owner":
    +    if body.role in ("owner", "admin"):
             if not await member_svc.has_role(workspace_id, user.id, "owner"):
                 raise HTTPException(
                     status_code=status.HTTP_403_FORBIDDEN,
    -                detail="Only owners can add another owner",
    +                detail="Only owners can add admin or owner roles",
                 )
         member = await member_svc.add(workspace_id, body.user_id, body.role)
         return MemberResponse.model_validate(member)
    @@ -141,12 +141,12 @@ async def update_member_role(
                 status_code=status.HTTP_403_FORBIDDEN,
                 detail="Cannot change your own role",
             )
    -    if body.role == "owner" and not await member_svc.has_role(
    +    if body.role in ("owner", "admin") and not await member_svc.has_role(
             workspace_id, user.id, "owner"
         ):
             raise HTTPException(
                 status_code=status.HTTP_403_FORBIDDEN,
    -            detail="Only owners can assign the owner role",
    +            detail="Only owners can assign admin or owner roles",
             )
         if target.role == "owner" and not await member_svc.has_role(
             workspace_id, user.id, "owner"
    
  • src/praisonai-platform/praisonai_platform/__main__.py+7 1 modified
    @@ -7,12 +7,18 @@
     """
     
     import argparse
    +import os
     import sys
     
     
     def main() -> None:
    +    default_host = os.environ.get("PLATFORM_HOST", "127.0.0.1")
         parser = argparse.ArgumentParser(description="PraisonAI Platform Server")
    -    parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
    +    parser.add_argument(
    +        "--host",
    +        default=default_host,
    +        help="Bind host (default: 127.0.0.1, or PLATFORM_HOST env)",
    +    )
         parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
         parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
         args = parser.parse_args()
    
  • src/praisonai-platform/praisonai_platform/services/agent_service.py+17 6 modified
    @@ -50,9 +50,16 @@ async def create(
             await self._session.flush()
             return agent
     
    -    async def get(self, agent_id: str) -> Optional[Agent]:
    -        """Get agent by ID."""
    -        return await self._session.get(Agent, agent_id)
    +    async def get(
    +        self, agent_id: str, *, workspace_id: Optional[str] = None
    +    ) -> Optional[Agent]:
    +        """Get agent by ID, optionally scoped to a workspace."""
    +        agent = await self._session.get(Agent, agent_id)
    +        if agent is None:
    +            return None
    +        if workspace_id is not None and agent.workspace_id != workspace_id:
    +            return None
    +        return agent
     
         async def list_for_workspace(
             self,
    @@ -72,6 +79,8 @@ async def list_for_workspace(
         async def update(
             self,
             agent_id: str,
    +        *,
    +        workspace_id: Optional[str] = None,
             name: Optional[str] = None,
             status: Optional[str] = None,
             instructions: Optional[str] = None,
    @@ -80,7 +89,7 @@ async def update(
             max_concurrent_tasks: Optional[int] = None,
         ) -> Optional[Agent]:
             """Update agent fields."""
    -        agent = await self.get(agent_id)
    +        agent = await self.get(agent_id, workspace_id=workspace_id)
             if agent is None:
                 return None
             if name is not None:
    @@ -102,9 +111,11 @@ async def update(
             await self._session.flush()
             return agent
     
    -    async def delete(self, agent_id: str) -> bool:
    +    async def delete(
    +        self, agent_id: str, *, workspace_id: Optional[str] = None
    +    ) -> bool:
             """Delete an agent."""
    -        agent = await self.get(agent_id)
    +        agent = await self.get(agent_id, workspace_id=workspace_id)
             if agent is None:
                 return False
             await self._session.delete(agent)
    
  • src/praisonai-platform/praisonai_platform/services/issue_service.py+17 6 modified
    @@ -69,9 +69,16 @@ async def create(
             await self._session.flush()
             return issue
     
    -    async def get(self, issue_id: str) -> Optional[Issue]:
    -        """Get issue by ID."""
    -        return await self._session.get(Issue, issue_id)
    +    async def get(
    +        self, issue_id: str, *, workspace_id: Optional[str] = None
    +    ) -> Optional[Issue]:
    +        """Get issue by ID, optionally scoped to a workspace."""
    +        issue = await self._session.get(Issue, issue_id)
    +        if issue is None:
    +            return None
    +        if workspace_id is not None and issue.workspace_id != workspace_id:
    +            return None
    +        return issue
     
         async def list_for_workspace(
             self,
    @@ -97,6 +104,8 @@ async def list_for_workspace(
         async def update(
             self,
             issue_id: str,
    +        *,
    +        workspace_id: Optional[str] = None,
             title: Optional[str] = None,
             description: Optional[str] = None,
             status: Optional[str] = None,
    @@ -106,7 +115,7 @@ async def update(
             project_id: Optional[str] = None,
         ) -> Optional[Issue]:
             """Update issue fields."""
    -        issue = await self.get(issue_id)
    +        issue = await self.get(issue_id, workspace_id=workspace_id)
             if issue is None:
                 return None
             if title is not None:
    @@ -147,9 +156,11 @@ async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]:
             """Transition an issue to a new status."""
             return await self.update(issue_id, status=new_status)
     
    -    async def delete(self, issue_id: str) -> bool:
    +    async def delete(
    +        self, issue_id: str, *, workspace_id: Optional[str] = None
    +    ) -> bool:
             """Delete an issue."""
    -        issue = await self.get(issue_id)
    +        issue = await self.get(issue_id, workspace_id=workspace_id)
             if issue is None:
                 return False
             await self._session.delete(issue)
    
  • src/praisonai-platform/praisonai_platform/services/project_service.py+17 6 modified
    @@ -44,9 +44,16 @@ async def create(
             await self._session.flush()
             return project
     
    -    async def get(self, project_id: str) -> Optional[Project]:
    -        """Get project by ID."""
    -        return await self._session.get(Project, project_id)
    +    async def get(
    +        self, project_id: str, *, workspace_id: Optional[str] = None
    +    ) -> Optional[Project]:
    +        """Get project by ID, optionally scoped to a workspace."""
    +        project = await self._session.get(Project, project_id)
    +        if project is None:
    +            return None
    +        if workspace_id is not None and project.workspace_id != workspace_id:
    +            return None
    +        return project
     
         async def list_for_workspace(
             self,
    @@ -62,14 +69,16 @@ async def list_for_workspace(
         async def update(
             self,
             project_id: str,
    +        *,
    +        workspace_id: Optional[str] = None,
             title: Optional[str] = None,
             description: Optional[str] = None,
             status: Optional[str] = None,
             lead_type: Optional[str] = None,
             lead_id: Optional[str] = None,
         ) -> Optional[Project]:
             """Update project fields."""
    -        project = await self.get(project_id)
    +        project = await self.get(project_id, workspace_id=workspace_id)
             if project is None:
                 return None
             if title is not None:
    @@ -85,9 +94,11 @@ async def update(
             await self._session.flush()
             return project
     
    -    async def delete(self, project_id: str) -> bool:
    +    async def delete(
    +        self, project_id: str, *, workspace_id: Optional[str] = None
    +    ) -> bool:
             """Delete a project."""
    -        project = await self.get(project_id)
    +        project = await self.get(project_id, workspace_id=workspace_id)
             if project is None:
                 return False
             await self._session.delete(project)
    
  • src/praisonai-platform/tests/test_service_workspace_scope.py+38 0 added
    @@ -0,0 +1,38 @@
    +"""Service-layer workspace scoping for issues and projects."""
    +
    +from __future__ import annotations
    +
    +import pytest
    +
    +from praisonai_platform.services.auth_service import AuthService
    +from praisonai_platform.services.issue_service import IssueService
    +from praisonai_platform.services.project_service import ProjectService
    +from praisonai_platform.services.workspace_service import WorkspaceService
    +
    +
    +@pytest.mark.asyncio
    +async def test_issue_get_rejects_wrong_workspace(session):
    +    auth = AuthService(session)
    +    user, _ = await auth.register("scope@test.com", "pass")
    +    ws_a = await WorkspaceService(session).create("A", user.id)
    +    ws_b = await WorkspaceService(session).create("B", user.id)
    +    issue_svc = IssueService(session)
    +    issue = await issue_svc.create(
    +        workspace_id=ws_a.id,
    +        title="secret",
    +        creator_id=user.id,
    +    )
    +    assert await issue_svc.get(issue.id, workspace_id=ws_a.id) is not None
    +    assert await issue_svc.get(issue.id, workspace_id=ws_b.id) is None
    +
    +
    +@pytest.mark.asyncio
    +async def test_project_delete_scoped_to_workspace(session):
    +    auth = AuthService(session)
    +    user, _ = await auth.register("proj_scope@test.com", "pass")
    +    ws_a = await WorkspaceService(session).create("PA", user.id)
    +    ws_b = await WorkspaceService(session).create("PB", user.id)
    +    proj_svc = ProjectService(session)
    +    project = await proj_svc.create(workspace_id=ws_a.id, title="p1")
    +    assert await proj_svc.delete(project.id, workspace_id=ws_b.id) is False
    +    assert await proj_svc.delete(project.id, workspace_id=ws_a.id) is True
    
3ea837661036

refactor: harden platform scoping and deploy output sanitisation

https://github.com/MervinPraison/PraisonAIpraisonMay 19, 2026Fixed in 4.6.40via llm-release-walk
13 files changed · +212 19
  • examples/python/managed-agents/provider/local_advanced.py+31 4 modified
    @@ -23,12 +23,39 @@
     result = agent.start("What is 123 + 456?", stream=True)
     
     # ── 3. Custom tool ──
    -def handle_calculator(tool_name, tool_input):
    -    expr = tool_input.get("expression", "0")
    +def _safe_calc(expr: str) -> str:
    +    import ast
    +    allowed = set("0123456789+-*/.() ")
    +    if not all(c in allowed for c in expr):
    +        return "error"
         try:
    -        val = eval(expr, {"__builtins__": {}})
    +        tree = ast.parse(expr, mode="eval")
    +        for node in ast.walk(tree):
    +            if not isinstance(
    +                node,
    +                (
    +                    ast.Expression,
    +                    ast.BinOp,
    +                    ast.UnaryOp,
    +                    ast.Constant,
    +                    ast.Add,
    +                    ast.Sub,
    +                    ast.Mult,
    +                    ast.Div,
    +                    ast.USub,
    +                    ast.UAdd,
    +                ),
    +            ):
    +                return "error"
    +        val = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
    +        return str(val)
         except Exception:
    -        val = "error"
    +        return "error"
    +
    +
    +def handle_calculator(tool_name, tool_input):
    +    expr = tool_input.get("expression", "0")
    +    val = _safe_calc(str(expr))
         print(f"  [Calculator: {expr} = {val}]")
         return str(val)
     
    
  • examples/serve/mcp_http_server.py+26 5 modified
    @@ -44,12 +44,33 @@ def search(query: str) -> str:
         
         def calculate(expression: str) -> str:
             """Calculate a math expression safely."""
    -        try:
    -            # Safe eval for basic math
    -            allowed = set("0123456789+-*/.() ")
    -            if all(c in allowed for c in expression):
    -                return str(eval(expression))
    +        import ast
    +
    +        allowed = set("0123456789+-*/.() ")
    +        if not all(c in allowed for c in expression):
                 return "Error: Invalid expression"
    +        try:
    +            tree = ast.parse(expression, mode="eval")
    +            for node in ast.walk(tree):
    +                if not isinstance(
    +                    node,
    +                    (
    +                        ast.Expression,
    +                        ast.BinOp,
    +                        ast.UnaryOp,
    +                        ast.Constant,
    +                        ast.Add,
    +                        ast.Sub,
    +                        ast.Mult,
    +                        ast.Div,
    +                        ast.USub,
    +                        ast.UAdd,
    +                    ),
    +                ):
    +                    return "Error: Invalid expression"
    +            return str(
    +                eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
    +            )
             except Exception as e:
                 return f"Error: {e}"
         
    
  • SECURITY_TRIAGE.md+22 0 added
    @@ -0,0 +1,22 @@
    +# Security advisory triage (maintainer)
    +
    +| GHSA | Status after batch 1+2 | Notes |
    +|------|------------------------|-------|
    +| GHSA-4mr5-g6f9-cfrh | fixed batch 1 | Sandbox AST |
    +| GHSA-5c6w-wwfq-7qqm | fixed batch 1 | spider_tools SSRF |
    +| GHSA-9cr9-25q5-8prj | fixed batch 1 | MCP yaml paths |
    +| GHSA-86qc-r5v2-v6x6 | fixed batch 1 | call server auth |
    +| GHSA-hvhp-v2gc-268q | fixed batch 1 | write_file cwd |
    +| GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions |
    +| GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote |
    +| GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard |
    +| GHSA-xwq8, 7p8g, c2m8, 8g2p, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial |
    +| GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion |
    +| GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening |
    +| GHSA-6xj3-927j-6pqw | fixed batch 2 | deploy.py bleach sanitize |
    +| GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True |
    +| GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate |
    +| GHSA-gv23, h8q5, 6h6v, h37g | partial/defer | broader platform audit |
    +| GHSA-gmjg, 9q28 | published | prior release |
    +
    +Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
    
  • src/praisonai-platform/praisonai_platform/api/deps.py+18 0 modified
    @@ -107,3 +107,21 @@ def ensure_resource_in_workspace(
                 status_code=status.HTTP_404_NOT_FOUND,
                 detail=f"{label} not found",
             )
    +
    +
    +async def require_issue_in_workspace(
    +    workspace_id: str,
    +    issue_id: str,
    +    session: AsyncSession,
    +):
    +    """Load an issue and verify it belongs to the URL workspace."""
    +    from ..db.models import Issue
    +
    +    issue = await session.get(Issue, issue_id)
    +    if issue is None:
    +        raise HTTPException(
    +            status_code=status.HTTP_404_NOT_FOUND,
    +            detail="Issue not found",
    +        )
    +    ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
    +    return issue
    
  • src/praisonai-platform/praisonai_platform/api/routes/activity.py+2 1 modified
    @@ -9,7 +9,7 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import get_db, require_workspace_member
    +from ..deps import get_db, require_issue_in_workspace, require_workspace_member
     from ..schemas import ActivityLogResponse
     from ...services.activity_service import ActivityService
     
    @@ -38,6 +38,7 @@ async def list_issue_activity(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    await require_issue_in_workspace(workspace_id, issue_id, session)
         svc = ActivityService(session)
         logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset)
         return [ActivityLogResponse.model_validate(log) for log in logs]
    
  • src/praisonai-platform/praisonai_platform/api/routes/dependencies.py+11 2 modified
    @@ -9,9 +9,9 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import get_db, require_workspace_member
    -from ..schemas import DependencyCreate, DependencyResponse
    +from ..deps import get_db, require_issue_in_workspace, require_workspace_member
     from ...services.dependency_service import DependencyService
    +from ..schemas import DependencyCreate, DependencyResponse
     
     router = APIRouter(
         prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies",
    @@ -27,6 +27,10 @@ async def create_dependency(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    await require_issue_in_workspace(workspace_id, issue_id, session)
    +    await require_issue_in_workspace(
    +        workspace_id, body.depends_on_issue_id, session
    +    )
         svc = DependencyService(session)
         dep = await svc.create(issue_id, body.depends_on_issue_id, body.type)
         return DependencyResponse.model_validate(dep)
    @@ -39,6 +43,7 @@ async def list_dependencies(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    await require_issue_in_workspace(workspace_id, issue_id, session)
         svc = DependencyService(session)
         deps = await svc.list_for_issue(issue_id)
         return [DependencyResponse.model_validate(d) for d in deps]
    @@ -52,7 +57,11 @@ async def delete_dependency(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    await require_issue_in_workspace(workspace_id, issue_id, session)
         svc = DependencyService(session)
    +    dep = await svc.get(dep_id)
    +    if dep is None:
    +        raise HTTPException(status_code=404, detail="Dependency not found")
         deleted = await svc.delete(dep_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Dependency not found")
    
  • src/praisonai-platform/praisonai_platform/api/routes/labels.py+25 1 modified
    @@ -9,7 +9,12 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import get_db, require_workspace_member
    +from ..deps import (
    +    ensure_resource_in_workspace,
    +    get_db,
    +    require_issue_in_workspace,
    +    require_workspace_member,
    +)
     from ..schemas import LabelCreate, LabelResponse, LabelUpdate
     from ...services.label_service import LabelService
     
    @@ -48,6 +53,10 @@ async def update_label(
         session: AsyncSession = Depends(get_db),
     ):
         svc = LabelService(session)
    +    label = await svc.get(label_id)
    +    if label is None:
    +        raise HTTPException(status_code=404, detail="Label not found")
    +    ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
         label = await svc.update(label_id, body.name, body.color)
         if label is None:
             raise HTTPException(status_code=404, detail="Label not found")
    @@ -62,6 +71,10 @@ async def delete_label(
         session: AsyncSession = Depends(get_db),
     ):
         svc = LabelService(session)
    +    label = await svc.get(label_id)
    +    if label is None:
    +        raise HTTPException(status_code=404, detail="Label not found")
    +    ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
         deleted = await svc.delete(label_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Label not found")
    @@ -78,7 +91,12 @@ async def add_label_to_issue(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    await require_issue_in_workspace(workspace_id, issue_id, session)
         svc = LabelService(session)
    +    label = await svc.get(label_id)
    +    if label is None:
    +        raise HTTPException(status_code=404, detail="Label not found")
    +    ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
         await svc.add_to_issue(issue_id, label_id)
     
     
    @@ -90,7 +108,12 @@ async def remove_label_from_issue(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    await require_issue_in_workspace(workspace_id, issue_id, session)
         svc = LabelService(session)
    +    label = await svc.get(label_id)
    +    if label is None:
    +        raise HTTPException(status_code=404, detail="Label not found")
    +    ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
         await svc.remove_from_issue(issue_id, label_id)
     
     
    @@ -101,6 +124,7 @@ async def list_issue_labels(
         user: AuthIdentity = Depends(require_workspace_member),
         session: AsyncSession = Depends(get_db),
     ):
    +    await require_issue_in_workspace(workspace_id, issue_id, session)
         svc = LabelService(session)
         labels = await svc.list_for_issue(issue_id)
         return [LabelResponse.model_validate(l) for l in labels]
    
  • src/praisonai-platform/praisonai_platform/api/routes/projects.py+11 1 modified
    @@ -9,7 +9,7 @@
     
     from praisonaiagents.auth import AuthIdentity
     
    -from ..deps import get_db, require_workspace_member
    +from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
     from ..schemas import ProjectCreate, ProjectResponse, ProjectUpdate
     from ...services.project_service import ProjectService
     
    @@ -59,6 +59,7 @@ async def get_project(
         project = await svc.get(project_id)
         if project is None:
             raise HTTPException(status_code=404, detail="Project not found")
    +    ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project")
         return ProjectResponse.model_validate(project)
     
     
    @@ -81,6 +82,7 @@ async def update_project(
         )
         if project is None:
             raise HTTPException(status_code=404, detail="Project not found")
    +    ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project")
         return ProjectResponse.model_validate(project)
     
     
    @@ -92,6 +94,10 @@ async def delete_project(
         session: AsyncSession = Depends(get_db),
     ):
         svc = ProjectService(session)
    +    project = await svc.get(project_id)
    +    if project is None:
    +        raise HTTPException(status_code=404, detail="Project not found")
    +    ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project")
         deleted = await svc.delete(project_id)
         if not deleted:
             raise HTTPException(status_code=404, detail="Project not found")
    @@ -105,4 +111,8 @@ async def project_stats(
         session: AsyncSession = Depends(get_db),
     ):
         svc = ProjectService(session)
    +    project = await svc.get(project_id)
    +    if project is None:
    +        raise HTTPException(status_code=404, detail="Project not found")
    +    ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project")
         return await svc.get_stats(project_id)
    
  • src/praisonai-platform/praisonai_platform/services/workspace_service.py+5 2 modified
    @@ -10,7 +10,7 @@
     import re
     from typing import Optional
     
    -from sqlalchemy import select
    +from sqlalchemy import delete, select
     from sqlalchemy.ext.asyncio import AsyncSession
     
     from ..db.models import Member, Workspace
    @@ -98,10 +98,13 @@ async def update(
             return ws
     
         async def delete(self, workspace_id: str) -> bool:
    -        """Delete a workspace."""
    +        """Delete a workspace and its memberships."""
             ws = await self.get(workspace_id)
             if ws is None:
                 return False
    +        await self._session.execute(
    +            delete(Member).where(Member.workspace_id == workspace_id)
    +        )
             await self._session.delete(ws)
             await self._session.flush()
             return True
    
  • src/praisonai-platform/tests/test_resource_idor.py+20 0 added
    @@ -0,0 +1,20 @@
    +"""Cross-workspace IDOR guards for platform resources."""
    +
    +from __future__ import annotations
    +
    +import pytest
    +from fastapi import HTTPException
    +
    +from praisonai_platform.api.deps import ensure_resource_in_workspace
    +
    +
    +def test_project_workspace_mismatch():
    +    with pytest.raises(HTTPException) as exc:
    +        ensure_resource_in_workspace("ws-other", "ws-mine", label="Project")
    +    assert exc.value.status_code == 404
    +
    +
    +def test_label_workspace_mismatch():
    +    with pytest.raises(HTTPException) as exc:
    +        ensure_resource_in_workspace("ws-other", "ws-mine", label="Label")
    +    assert exc.value.status_code == 404
    
  • src/praisonai/praisonai/deploy.py+5 3 modified
    @@ -76,16 +76,18 @@ def create_api_file(self):
             with open("api.py", "w") as file:
                 file.write("from flask import Flask\n")
                 file.write("from praisonai import PraisonAI\n")
    -            file.write("import markdown\n\n")
    +            file.write("import markdown\n")
    +            file.write("import bleach\n\n")
                 file.write("app = Flask(__name__)\n\n")
                 file.write("def basic():\n")
                 file.write("    praisonai = PraisonAI(agent_file=\"agents.yaml\")\n")
                 file.write("    return praisonai.run()\n\n")
                 file.write("@app.route('/')\n")
                 file.write("def home():\n")
                 file.write("    output = basic()\n")
    -            file.write("    html_output = markdown.markdown(output)\n")
    -            file.write("    return f'<html><body>{html_output}</body></html>'\n\n")
    +            file.write("    rendered = markdown.markdown(str(output))\n")
    +            file.write("    safe_html = bleach.clean(rendered, tags=bleach.sanitizer.ALLOWED_TAGS, attributes=bleach.sanitizer.ALLOWED_ATTRIBUTES)\n")
    +            file.write("    return f'<html><body>{safe_html}</body></html>'\n\n")
                 file.write("if __name__ == \"__main__\":\n")
                 file.write("    import os\n")
                 file.write("    app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
    
  • src/praisonai/tests/unit/deploy/test_api_auth_default.py+19 0 added
    @@ -0,0 +1,19 @@
    +"""Generated deploy API servers must enable auth by default."""
    +
    +from praisonai.deploy.api import generate_api_server_code
    +from praisonai.deploy.models import APIConfig
    +
    +
    +def test_generate_api_server_auth_enabled_by_default():
    +    code = generate_api_server_code("agents.yaml", APIConfig())
    +    assert "AUTH_ENABLED" in code
    +    assert "'enabled'" in code or '"enabled"' in code
    +    assert "check_auth" in code
    +    assert "compare_digest" in code
    +
    +
    +def test_generate_api_server_respects_disabled_config():
    +    code = generate_api_server_code(
    +        "agents.yaml", APIConfig(auth_enabled=False)
    +    )
    +    assert "'disabled'" in code or '"disabled"' in code
    
  • src/praisonai/tests/unit/test_agents_generator_safe_loader.py+17 0 added
    @@ -0,0 +1,17 @@
    +"""load_tools_from_module must use gated safe loader."""
    +
    +from __future__ import annotations
    +
    +from unittest.mock import patch
    +
    +import pytest
    +
    +
    +def test_load_tools_from_module_returns_empty_when_blocked():
    +    from praisonai.agents_generator import AgentsGenerator
    +
    +    gen = object.__new__(AgentsGenerator)
    +    with patch(
    +        "praisonai._safe_loader.load_user_module", return_value=None
    +    ):
    +        assert gen.load_tools_from_module("/tmp/evil_tools.py") == {}
    
a72e156c4d01

Release v4.6.40

https://github.com/MervinPraison/PraisonAIpraisonMay 19, 2026Fixed in 4.6.40via release-tag
12 files changed · +13 13
  • docker/Dockerfile.chat+1 1 modified
    @@ -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.6.39" \
    +    "praisonai>=4.6.40" \
         "praisonai[chat]" \
         "embedchain[github,youtube]"
     
    
  • docker/Dockerfile.dev+1 1 modified
    @@ -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.6.39" \
    +    "praisonai>=4.6.40" \
         "praisonai[ui]" \
         "praisonai[chat]" \
         "praisonai[realtime]" \
    
  • docker/Dockerfile.ui+1 1 modified
    @@ -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.6.39" \
    +    "praisonai>=4.6.40" \
         "praisonai[ui]" \
         "praisonai[crewai]"
     
    
  • src/praisonai-agents/pyproject.toml+1 1 modified
    @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
     
     [project]
     name = "praisonaiagents"
    -version = "1.6.39"
    +version = "1.6.40"
     description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
     readme = "README.md"
     requires-python = ">=3.10"
    
  • src/praisonai-agents/uv.lock+1 1 modified
    @@ -2992,7 +2992,7 @@ wheels = [
     
     [[package]]
     name = "praisonaiagents"
    -version = "1.6.39"
    +version = "1.6.40"
     source = { editable = "." }
     dependencies = [
         { name = "aiohttp" },
    
  • src/praisonai-platform/pyproject.toml+1 1 modified
    @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
     
     [project]
     name = "praisonai-platform"
    -version = "0.1.2"
    +version = "0.1.4"
     description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
     readme = "README.md"
     requires-python = ">=3.10"
    
  • src/praisonai-platform/uv.lock+1 1 modified
    @@ -1208,7 +1208,7 @@ wheels = [
     
     [[package]]
     name = "praisonai-platform"
    -version = "0.1.2"
    +version = "0.1.4"
     source = { editable = "." }
     dependencies = [
         { name = "aiosqlite" },
    
  • src/praisonai/praisonai/deploy.py+1 1 modified
    @@ -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.6.39 gunicorn markdown\n")
    +            file.write("RUN pip install flask praisonai==4.6.40 gunicorn markdown\n")
                 file.write("EXPOSE 8080\n")
                 file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
                 
    
  • src/praisonai/praisonai.rb+2 2 modified
    @@ -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.6.39.tar.gz"
    -    sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.39.tar.gz | shasum -a 256`.split.first
    +    url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz"
    +    sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz | shasum -a 256`.split.first
         license "MIT"
       
         depends_on "python@3.11"
    
  • src/praisonai/praisonai/version.py+1 1 modified
    @@ -1 +1 @@
    -__version__ = "4.6.39"
    \ No newline at end of file
    +__version__ = "4.6.40"
    \ No newline at end of file
    
  • src/praisonai/pyproject.toml+1 1 modified
    @@ -12,7 +12,7 @@ dependencies = [
         "rich>=13.7",
         "markdown>=3.5",
         "pyparsing>=3.0.0",
    -    "praisonaiagents>=1.6.39",
    +    "praisonaiagents>=1.6.40",
         "python-dotenv>=0.19.0",
         "litellm>=1.83.14,<2",
         "PyYAML>=6.0",
    
  • src/praisonai/uv.lock+1 1 modified
    @@ -4841,7 +4841,7 @@ wheels = [
     
     [[package]]
     name = "praisonaiagents"
    -version = "1.6.39"
    +version = "1.6.40"
     source = { directory = "../praisonai-agents" }
     dependencies = [
         { name = "aiohttp" },
    

Vulnerability mechanics

Root cause

"Missing path containment validation in three MCP tool handlers and no input schema enforcement in the dispatcher, allowing arbitrary file read."

Attack vector

An attacker sends a single unauthenticated MCP `tools/call` JSON-RPC request to `praisonai.workflow.show`, `praisonai.workflow.validate`, or `praisonai.deploy.validate` with an arbitrary file path (e.g., `/etc/passwd`, `~/.ssh/id_rsa`, `~/.aws/credentials`). The server reads the file and returns its contents directly (or leaks content fragments via YAML parser error messages). No authentication is required because `api_key` defaults to `None` and the auth check is gated on `if self.api_key:` [ref_id=1][ref_id=2]. The same handlers are reachable on the HTTP stream transport (default `127.0.0.1`) and the stdio transport used by MCP clients like Claude Desktop [ref_id=1].

Affected code

The vulnerability resides in `src/praisonai/praisonai/mcp_server/adapters/cli_tools.py` (handlers `workflow.show`, `workflow.validate`, `deploy.validate`) and the dispatcher `_handle_tools_call` in `server.py:281-298`. The prior patch (commit `68cc9427`) hardened `rules.create`, `rules.show`, and `rules.delete` but left `workflow.show` unchanged; `workflow.validate` and `deploy.validate` were never mentioned in the original advisory and remain unpatched. The dispatcher accepts unvalidated `**kwargs` from `params["arguments"]` with no enforcement against the tool's declared `input_schema`, which is unchanged in HEAD as of commit `42221210` [ref_id=1][ref_id=2].

What the fix does

The patch at `patch_id=3131094` applies the same `_resolve_rule_path()` containment pattern that was previously added for the rules handlers to `workflow.show`, `workflow.validate`, and `deploy.validate`. Each handler now resolves the user-supplied path against a restricted root directory (`~/.praison/workflows` or a deploy-config root) and rejects paths that escape that root via `candidate.relative_to(workflows_root)`. This prevents arbitrary file reads by confining file access to a designated safe directory. The dispatcher-level fix (enforcing `tool.input_schema` in `server.py:281-298`) is not included in this patch, so other handlers like `audio.transcribe` and `files.create` remain reachable through the same unvalidated `**kwargs` pattern [ref_id=1][ref_id=2].

Preconditions

  • configThe MCP server must be running with default configuration (api_key=None)
  • networkThe attacker must be able to send MCP tools/call JSON-RPC messages to the server (reachable on loopback or stdio)
  • authNo authentication is required; the default api_key=None bypasses the auth check entirely
  • inputThe attacker supplies an arbitrary file path string as the file_path or config_path argument

Reproduction

The bundle includes a full PoC with two shell scripts. In Terminal 1, run `bash poc/start_mcp_server.sh` to start the real MCPServer on `127.0.0.1:8766/mcp` with no auth. In Terminal 2, run `bash poc/run_mcp_poc_video.sh` which sends six curl commands. `workflow.show` with a path to a synthetic credential file returns its full contents; `workflow.validate` with `/etc/hosts` leaks a YAML parser error message confirming file existence and content fragments; `deploy.validate` with `~/.ssh/known_hosts` similarly leaks file content via parser errors [ref_id=1][ref_id=2].

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.