{"id":"CVE-2026-56838","aliases":["PYSEC-2026-3520"],"url":"https://o3.security/vulnerability/CVE-2026-56838","summary":"PraisonAI recipe.run_stream skips dangerous-tool policy enforcement","details":"# PraisonAI `recipe.run_stream()` skips dangerous-tool policy enforcement\n\n## Summary\n\nPraisonAI recipe execution blocks default-denied dangerous tools unless the\ncaller explicitly passes `allow_dangerous_tools=True`. The normal `recipe.run()`\npath enforces this with `_check_tool_policy()`. The streaming path,\n`recipe.run_stream()`, loads the same recipe, checks dependencies, and then\ncalls `_execute_recipe()` without running the dangerous-tool policy check.\n\nAs a result, a recipe that honestly declares `execute_command` in\n`TEMPLATE.yaml requires.tools` is denied by `recipe.run()`, but reaches the\nexecution engine through `recipe.run_stream()` with\n`allow_dangerous_tools=False`.\n\nThe local PoV uses a harmless `printf` canary, explicitly unsets\n`PRAISONAI_AUTO_APPROVE`, and avoids network access.\n\n## Affected Product\n\n- Repository: `MervinPraison/PraisonAI`\n- Package: `praisonai`\n- Components:\n  - `src/praisonai/praisonai/recipe/core.py`\n  - `src/praisonai/praisonai/recipe/serve.py`\n  - `src/praisonai/praisonai/cli/features/recipe.py`\n  - `src/praisonai-agents/praisonaiagents/workflows/yaml_parser.py`\n  - `src/praisonai-agents/praisonaiagents/workflows/workflows.py`\n\nValidated affected:\n\n- current main `2f9677abb2ea68eab864ee8b6a828fd0141612e1`\n  (`v4.6.57-4-g2f9677ab`)\n- `v4.6.57`\n- `v4.6.56`\n- `v4.6.10`\n- `v4.6.9`\n- `v4.5.128`\n- `v4.5.120`\n- `v4.5.96`\n- `v4.5.87`\n\nSuggested affected range: `>= 4.5.87, <= 4.6.57`.\n\nPyPI lists `PraisonAI 4.6.57` as the latest release on 2026-06-13.\n\nEarlier tested tags through `v4.5.85` failed in this source checkout before the\ntested workflow path due an unrelated `praisonaiagents.output.models` import\nerror. They are not claimed fixed or unaffected.\n\n## Root Cause\n\n`recipe.run()` enforces the dangerous-tool gate:\n\n```python\nif not options.get(\"allow_dangerous_tools\", False):\n    policy_error = _check_tool_policy(recipe_config)\n    if policy_error:\n        return RecipeResult(..., status=RecipeStatus.POLICY_DENIED, ...)\n```\n\n`recipe.run_stream()` has a sibling execution path. It loads the recipe and\nchecks dependencies, but then goes directly to execution:\n\n```python\nrecipe_config = _load_recipe(name, offline=options.get(\"offline\", False))\n...\noutput = _execute_recipe(recipe_config, merged_config, session_id, options)\n```\n\nThere is no equivalent `_check_tool_policy()` call in `run_stream()` before\nexecution or before the dry-run shortcut.\n\nThe CLI exposes this path via `praisonai recipe run <recipe> --stream`, and the\nrecipe HTTP server exposes it as `POST /v1/recipes/stream`.\n\n## Why This Is Not Intended Behavior\n\nThe normal recipe path clearly treats declared dangerous tools as denied by\ndefault. A control recipe with `TEMPLATE.yaml requires.tools:\n[execute_command]` returns:\n\n```text\nTool 'execute_command' is denied by default. Use allow_dangerous_tools=True to override.\n```\n\nThat operator-facing override should not depend on whether the caller requests\nstreaming output. PraisonAI's own docs describe approval as requiring a human\nor configured channel before risky tools run, describe security environment\nvariables as opt-in access for dangerous operations with secure defaults, and\ndescribe policy controls as blocking dangerous operations.\n\nThis is distinct from the prior report `PRAI-CAND-011`:\n\n- `PRAI-CAND-011` covers workflow tool declarations that are omitted from\n  `TEMPLATE.yaml requires.tools`.\n- This report covers a sibling entrypoint that skips the policy check even when\n  `TEMPLATE.yaml` correctly declares the dangerous tool.\n\nIt is also distinct from the published Recipe-server authentication fail-open\nadvisory. That advisory covers missing authentication secrets. This report\nassumes the attacker has whatever access is already needed to invoke recipe\nstreaming and focuses on the missing dangerous-tool policy guard.\n\n## Local PoV\n\nRun:\n\n```bash\npython3 poc/pov_prai_cand_012_stream_policy_bypass.py\n```\n\nExpected output includes:\n\n```json\n{\n  \"ok\": true,\n  \"policy_error\": \"Tool 'execute_command' is denied by default. Use allow_dangerous_tools=True to override.\",\n  \"control_recipe_status\": \"policy_denied\",\n  \"execution_reached\": [\n    {\n      \"recipe\": \"declared-dangerous-stream\",\n      \"declared_required_tools\": [\"execute_command\"],\n      \"allow_dangerous_tools\": false\n    }\n  ],\n  \"workflow_approve_tools\": [\"execute_command\"],\n  \"runner_tool_names\": [\"execute_command\"],\n  \"command_stdout\": \"PRAI-CAND-012-CANARY\",\n  \"operator_env_auto_approve\": null\n}\n```\n\nThe PoV creates a temporary recipe that declares `execute_command` in\n`TEMPLATE.yaml requires.tools`.\n\nControl:\n\n- `recipe.run(..., options={\"force\": True})` returns `policy_denied`.\n\nBypass:\n\n- `recipe.run_stream(..., options={\"force\": True})` emits the `executing`\n  event and reaches `_execute_recipe()` while `allow_dangerous_tools` remains\n  false.\n- The same recipe workflow resolves `execute_command` and preserves\n  `approve: [execute_command]`.\n- With the workflow approval context installed, the resolved tool runs the\n  harmless local command `printf PRAI-CAND-012-CANARY`.\n\nThe PoV monkey-patches `_execute_recipe()` only to prove that\n`run_stream()` crosses the policy boundary without invoking an LLM. The command\ncanary is executed directly through the same resolved workflow tool and\napproval context to keep the proof deterministic and local-only.\n\n## Impact\n\nIf an operator runs an untrusted recipe through streaming mode, or exposes the\nrecipe streaming API to users who can choose recipe names or URIs, the recipe\ncan reach execution with default-denied tools even though the caller did not\nset `allow_dangerous_tools=True`.\n\nIf the workflow reaches the approved `execute_command` tool call, commands run\nwith the privileges of the PraisonAI process. The exact trigger depends on the\nworkflow and model/tool-call path, but the dangerous-tool policy boundary is\nalready bypassed before execution.\n\nThe HTTP recipe sidecar is documented as a localhost REST API with SSE\nstreaming and optional API-key/JWT authentication. This report does not claim\ndefault unauthenticated network RCE. In authenticated or exposed sidecar\ndeployments where lower-trust users can invoke `/v1/recipes/stream`, the same\npolicy gap can become a remote recipe-execution issue.\n\n## Suggested Fix\n\nCentralize recipe preflight enforcement so every execution mode uses the same\nguard:\n\n1. Run `_check_tool_policy(recipe_config)` in `run_stream()` unless\n   `options[\"allow_dangerous_tools\"]` is true.\n2. Perform that check before both dry-run and real execution, matching\n   `recipe.run()`.\n3. Prefer a shared helper for dependency checks, dangerous-tool policy checks,\n   and dry-run handling so future entrypoints cannot drift.\n4. Add regression tests:\n   - declared dangerous tool is denied by `recipe.run()`;\n   - the same declared dangerous tool is denied by `recipe.run_stream()`;\n   - `allow_dangerous_tools=True` preserves the intended opt-in behavior;\n   - `/v1/recipes/stream` maps a policy denial to a non-success SSE event or\n     equivalent HTTP failure.","published":"2026-06-18T13:53:05Z","modified":"2026-07-23T15:11:26.264669943Z","cvss":{"score":7.8,"severity":"HIGH","vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"praisonai","fixedVersion":"4.6.59"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-v847-hxxw-3pxg"},{"type":"PACKAGE","url":"https://github.com/MervinPraison/PraisonAI"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-23T15:11:26.264669943Z"}}