-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The permission tier is in the source. It is not running.\n\n`Agent.__init__` initializes the permission state at `agent.py:1749` (pre-patch line number, unchanged in 4.6.37):\n\n```python\nself._perm_deny = frozenset() # Permission tier deny set (empty = no denials)\nself._perm_allow = None # Permission tier allow set (None = allow all)\n```\n\nThe comment is correct. `None` means \"allow all.\" The block that follows mutates `_perm_deny` based on the `PRAISONAI_TOOL_SAFETY` environment variable and the constructor's `approval=` keyword. It never assigns to `_perm_allow`. The frozenset stays `None` unless a downstream caller assigns to it after construction, and the framework's call sites do not.\n\nAt call time, the gate runs in `_check_tool_approval_sync` and `_check_tool_approval_async`:\n\n```python\nif self._perm_deny and function_name in self._perm_deny:\n return {\"error\": f\"Tool '{function_name}' blocked by permission policy\",\n \"permission_denied\": True}\nif self._perm_allow is not None and function_name not in self._perm_allow:\n return {\"error\": f\"Tool '{function_name}' not in allowed tools list\",\n \"permission_denied\": True}\n```\n\nThe deny check filters destructive operations once `PRAISONAI_TOOL_SAFETY` is set to one of the presets; that is the only enforcement the gate carries by default. The allow check, the one that says \"only these named tools may execute,\" never engages on a default agent. The flag has the API surface of an enforced control and the runtime behavior of a hint.\n\n## What execute_tool does after the gate.\n\nThe LLM's tool-call name arrives at `execute_tool` raw, straight from the model's structured output. `chat_mixin.py:2596` in the pre-patch tree:\n\n```python\ntool_result = self.execute_tool(\n tool_call['function']['name'],\n parsed_args,\n tool_call_id=tool_call.get('id')\n)\n```\n\nThe string passed as `function_name` is `tool_call['function']['name']`, which is whatever the model decided to write. Inside `execute_tool`, the lookup walks four sources in order:\n\n```python\n# 1. agent.tools (declared)\nfor tool in self.tools if isinstance(self.tools, (list, tuple)) else []:\n if isinstance(tool, BaseTool) and tool.name == function_name:\n func = tool; break\n if hasattr(tool, 'name') and getattr(tool, 'name', None) == function_name:\n func = tool; break\n if (callable(tool) and getattr(tool, '__name__', '') == function_name) or \\\n (inspect.isclass(tool) and tool.__name__ == function_name):\n func = tool; break\n\n# 2. global tool registry (plugin system)\nif func is None:\n from ..tools.registry import get_registry\n func = get_registry().get(function_name)\n\n# 3. tool_execution.py's module globals\nif func is None:\n func = globals().get(function_name)\n if not func:\n # 4. __main__\n import __main__\n func = getattr(__main__, function_name, None)\n```\n\nSources 1 and 2 are what a developer would call \"declared tools.\" Sources 3 and 4 are anything the Python process imported or named at module level. `tool_execution.py` itself imports `os`, `inspect`, `logging`, `json`, `time`, `asyncio`, `traceback`, and most of `praisonaiagents`. The `__main__` module is whichever script the agent was launched from, with every function and class the script defined or imported at top level. Both are addressable by name through the same string that came off the wire from the model.\n\nThe PoC the GHSA ships is one undeclared function injected into `__main__`:\n\n```python\nimport sys\nfrom praisonaiagents import Agent\n\ndef _sneaky_undeclared(msg=\"pwned\"):\n return {\"ran\": msg}\n\nsys.modules[\"__main__\"]._sneaky_undeclared = _sneaky_undeclared\n\nagent = Agent(tools=[]) # no declared tools\nagent.execute_tool(\"_sneaky_undeclared\", {\"msg\": \"hello\"})\n# returns {\"ran\": \"hello\"}\n```\n\n`tools=[]` means no declared tools. `_perm_allow` defaults to `None`, so the allow-check does not fire. The deny preset does not list `_sneaky_undeclared`, because no preset does. `execute_tool` walks past `self.tools`, past the registry, calls `globals().get(\"_sneaky_undeclared\")` (None), imports `__main__`, calls `getattr(__main__, \"_sneaky_undeclared\", None)`, gets the function, casts the argument, calls it. The return value is the function's return value. The agent's response to its user is \"pwned.\"\n\n## The patch is two lines. The gate is unchanged.\n\n```diff\n if func is None:\n- # If not found in tools or registry, try globals and main\n- func = globals().get(function_name)\n- if not func:\n- import __main__\n- func = getattr(__main__, function_name, None)\n+ pass\n```\n\nAfter 4.6.37, `execute_tool` cannot resolve a name unless something in `self.tools` or the plugin registry matches. `_perm_allow` is still `None` by default. The allow-check still does not fire. The deny preset still filters whichever destructive operations the `PRAISONAI_TOOL_SAFETY` value names. The thing that now gates which callables can run is the fact that `globals()` and `__main__` are no longer consulted. The thing that was supposed to gate it, the permission tier the framework spent code on, is the same code it was before the CVE.\n\nThe patch's commit message is \"refactor: harden archive extraction and tool resolution boundary.\" The same commit hardens `recipe/registry.py`'s tar extraction against symlink escape. Two unrelated security fixes, one commit, neither mentioning CVE-2026-44339 or GHSA-gmjg-hv98-qggq in the body. The regression test the commit adds at `tests/unit/agent/test_tool_resolution_boundary.py` opens with `\"\"\"Regression tests for GHSA-gmjg-hv98-qggq:`. The test file names the advisory. The commit does not.\n\n## The function_name is the model's output. The model reads its prompt.\n\nA PraisonAI agent reads its prompt and emits tool calls. The prompt contains the system message, the user message, and anything else the integration has assembled: retrieved documents, web search results, file contents, prior tool outputs, email bodies, the contents of an arbitrary URL the user pasted into the chat. To the model, all of these are text in the same context window. Any of them can shape what the model writes next, including the `tool_calls[*].function.name` field.\n\nCVE-2026-44339 is content-is-command staged across two interpreters with the agent runtime in between. The model is the first interpreter; it reads attacker-shapeable prompt content and emits a structured response. The agent's `execute_tool` is the second interpreter; until 4.6.37 shipped, it took the model's `function.name` string and resolved it through `globals()` and `__main__`. The pattern's mechanism reads \"the interpreter, doing exactly what its spec says, does the wrong thing.\" Here the wrong thing is two interpreters doing the right thing on either side of a resolution call whose only safety claim was a frozenset that nobody assigned.\n\nThe closest sibling on the catalog is [adx-mcp-server](/posts/adx-mcp-three-safe-tools-call-execute), where an MCP client gates on the tool's name and forwards the model's `table_name` argument carrying KQL into a database engine. The shape is the same: prompt-shaped model output, structured tool call, interpreter at the bottom of the call stack that treats the model's output as a directive, gate that lives at a different layer than the one the data flows through. The two CVEs differ in which field of the structured tool call carries the payload. adx-mcp's payload was an argument value. PraisonAI's payload is the function-name slot itself. Both fields are model output. Both reach an interpreter.\n\nThis is also the trust-inversion shape one floor below the usual frame. The agent's tool-execution path is the authorizer; it confers the right to run Python in the process the agent was launched from. The compromise vector is not credential theft. The compromise vector is whatever shapes the model's next response. Validation on the distrusted lane (the user's chat input) was never the relevant gate, because the trusted lane (the LLM's structured tool-call output) was already executing the model's content.\n\n## The four lines shipped on January 30, 2025.\n\n`git log -S \"import __main__\" --all --pretty=\"%h %ad\" --date=short -- src/praisonai-agents/` returns three commits in this codebase. The earliest is `9594426f`, dated 2025-01-30, titled \"Add PraisonAI Agents SDK and Example Scripts.\" It is the first commit of the SDK. The `globals()` and `__main__` fallback was in that commit, in a function then called `execute_function` on the agent class. It moved into `tool_execution.py` on 2026-04-01 when the agent god class was split into mixins. It was removed on 2026-05-04, four hundred and fifty-nine days after it was written.\n\nThe advisory was filed on May 4, 2026. NVD assigned CVE-2026-44339 on May 8. GitHub published the advisory database entry on May 11. The fix landed the same day the advisory was filed, in a commit titled \"refactor: harden archive extraction and tool resolution boundary.\" A defender reading the v4.6.37 changelog without the advisory in hand sees \"refactor\" and \"harden\" and may not notice that four lines underneath the framework's documented permission tier were the framework's actual enforcement, for fifteen months.\n\nPoC: [@shmulc8 via GHSA-gmjg-hv98-qggq](https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-gmjg-hv98-qggq)","closing_line":"The patch removes the fallback. The permission tier is unchanged. The four lines underneath it were the enforcement all along.","hook_md":"PraisonAI 4.6.37, released May 4, removes four lines from `ToolExecutionMixin.execute_tool`. The four lines resolved any tool-call name the LLM emitted through `globals().get(function_name)` and `getattr(__main__, function_name, None)` when no declared tool matched. They had been in the SDK since its first commit on January 30, 2025. The advisory calls this \"unsafe tool resolution\" and assigns CWE-470.\n\nThere is a `_perm_deny` set. There is a `_perm_allow` set. There is a preset table mapping `safe`, `read_only`, `full`, `default`. There is an environment variable, `PRAISONAI_TOOL_SAFETY`, that selects one. There is an O(1) fast-path comment. The default value of `_perm_allow` is `None`. The fast-path is `if self._perm_allow is not None and function_name not in self._perm_allow`. When `None`, every name passes. The patch does not touch any of this.\n\nThe gate the developers wrote was never the gate. The fallback was.","post_id":276,"slug":"praisonai-permission-tier-was-never-the-gate","title":"CVE-2026-44339: PraisonAI's Permission Tier Was Never the Gate","type":"initial","unreadable_sentence":"The gate the developers wrote was never the gate. The fallback was."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaspxbAAKCRDeZjl4jgkQ JibLAP9L5CDts+ZGmvyNxBC0wFHoxuua0ycPbCMAYd/m0DyVvwD+Kt6bV531hY5e osWM/PFCwFQOiBXuTfFqYez/Xt6XvAw= =sfN1 -----END PGP SIGNATURE-----