GitHub Security Advisory: GHSA-xcmw-grxf-wjhj
Package: praisonai (PIP)
Severity: HIGH (CVSS 8.4)
CWE: CWE-94: Improper Control of Generation of Code ('Code Injection')
Vulnerable versions: >= 4.5.139, <= 4.6.31
Patched in: 4.6.32

Summary: PraisonAI has unauthenticated RCE via `tool_override.py` (CVE-2026-40287 patch bypass)

Details:
## TL;DR

CVE-2026-40287's fix gated `tools.py` auto-import behind `PRAISONAI_ALLOW_LOCAL_TOOLS=true` in **two** files (`tool_resolver.py`, `api/call.py`). A **third** import sink in `praisonai/templates/tool_override.py` was missed and remains unguarded. It is reached by the recipe runner on every recipe execution and is **remotely** triggerable through `POST /v1/recipes/run` with a `recipe` value pointing at any local absolute path *or* any GitHub repo (because `SecurityConfig.allow_any_github` defaults to `True`). The attacker drops a `tools.py` next to `TEMPLATE.yaml`; the server `exec_module()`s it. No auth required by default, no environment opt-in required.

## Patch coverage gap

CVE-2026-40287 was fixed in v4.5.139 by adding an env-var gate at:

| File | Line | Gate |
|---|---|---|
| `praisonai/tool_resolver.py` | 77 | `if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":` |
| `praisonai/api/call.py` | 80 | same |

But the equivalent sinks in `praisonai/templates/tool_override.py` were **not** patched:

```python
# tool_override.py - create_tool_registry_with_overrides()
332    cwd_tools_py = Path.cwd() / "tools.py"
333    if cwd_tools_py.exists():
334        try:
335            tools = loader.load_from_file(str(cwd_tools_py))   # <-- exec_module
336            registry.update(tools)
337        except Exception:
338            pass
339
341    # 4. Template-local tools.py
342    if template_dir:
343        tools_py = Path(template_dir) / "tools.py"
344        if tools_py.exists():
345            try:
346                tools = loader.load_from_file(str(tools_py))   # <-- exec_module
347                registry.update(tools)
348            except Exception:
349                pass
```

`load_from_file` (line 84-94) ends in `spec.loader.exec_module(module)` with no allowlist, no signature check, no env gate. Both call sites run unconditionally on every recipe execution.

## Attack chain

```
HTTP POST /v1/recipes/run
  body: {"recipe": "<abs path>" | "github:<owner>/<repo>/<recipe>"}
        │
        ▼
recipe/serve.py:483   run_recipe(request)              ← auth=none default
        │
        ▼
recipe/core.py:215    recipe.run(name, ...)
        │
        ▼
recipe/core.py:686    _load_recipe(name)
                      └─ ".." check only; absolute paths and URIs allowed
        │
        ▼
templates/loader.py:94    TemplateLoader.load(uri)
        │
        ▼
templates/security.py:130 is_source_allowed("github:*")
                          └─ allow_any_github=True default → returns True
        │
        ▼
templates/registry.py     fetch repo from raw.githubusercontent.com → cache dir
        │
        ▼
templates/security.py:215 validate_template_directory(cached.path)
                          └─ .py is in allowed_extensions → tools.py kept
        │
        ▼
recipe/core.py:887        _execute_recipe(recipe_config, ...)
        │
        ▼
recipe/core.py:943        create_tool_registry_with_overrides(