The permission tier is in the source. It is not running.
Agent.__init__ initializes the permission state at agent.py:1749 (pre-patch line number, unchanged in 4.6.37):
self._perm_deny = frozenset() # Permission tier deny set (empty = no denials)
self._perm_allow = None # Permission tier allow set (None = allow all)
The 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.
At call time, the gate runs in _check_tool_approval_sync and _check_tool_approval_async:
if self._perm_deny and function_name in self._perm_deny:
return {"error": f"Tool '{function_name}' blocked by permission policy",
"permission_denied": True}
if self._perm_allow is not None and function_name not in self._perm_allow:
return {"error": f"Tool '{function_name}' not in allowed tools list",
"permission_denied": True}
The 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.
What execute_tool does after the gate.
The 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:
tool_result = self.execute_tool(
tool_call['function']['name'],
parsed_args,
tool_call_id=tool_call.get('id')
)
The 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:
# 1. agent.tools (declared)
for tool in self.tools if isinstance(self.tools, (list, tuple)) else []:
if isinstance(tool, BaseTool) and tool.name == function_name:
func = tool; break
if hasattr(tool, 'name') and getattr(tool, 'name', None) == function_name:
func = tool; break
if (callable(tool) and getattr(tool, '__name__', '') == function_name) or \
(inspect.isclass(tool) and tool.__name__ == function_name):
func = tool; break
# 2. global tool registry (plugin system)
if func is None:
from ..tools.registry import get_registry
func = get_registry().get(function_name)
# 3. tool_execution.py's module globals
if func is None:
func = globals().get(function_name)
if not func:
# 4. __main__
import __main__
func = getattr(__main__, function_name, None)
Sources 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.
The PoC the GHSA ships is one undeclared function injected into __main__:
import sys
from praisonaiagents import Agent
def _sneaky_undeclared(msg="pwned"):
return {"ran": msg}
sys.modules["__main__"]._sneaky_undeclared = _sneaky_undeclared
agent = Agent(tools=[]) # no declared tools
agent.execute_tool("_sneaky_undeclared", {"msg": "hello"})
# returns {"ran": "hello"}
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."
The patch is two lines. The gate is unchanged.
if func is None:
- # If not found in tools or registry, try globals and main
- func = globals().get(function_name)
- if not func:
- import __main__
- func = getattr(__main__, function_name, None)
+ pass
After 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.
The 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.
The function_name is the model's output. The model reads its prompt.
A 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.
CVE-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.
The closest sibling on the catalog is adx-mcp-server, 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.
This 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.
The four lines shipped on January 30, 2025.
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.
The 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.
PoC: @shmulc8 via GHSA-gmjg-hv98-qggq
The patch removes the fallback. The permission tier is unchanged. The four lines underneath it were the enforcement all along.