{"id":"CVE-2026-56074","aliases":["PYSEC-2026-2946","GHSA-ffp3-3562-8cv3"],"title":"PraisonAI: Coarse-Grained Tool Approval Cache Bypasses Per-Invocation Consent for Shell Commands","summary":"PraisonAI: Coarse-Grained Tool Approval Cache Bypasses Per-Invocation Consent for Shell Commands","severity":"medium","cvss":5.5,"cvssVector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N","vendor":"praisonaiagents","product":"praisonaiagents","ecosystem":"pip","affected":["praisonaiagents < 4.5.128"],"patched":["praisonaiagents 4.5.128"],"published":"2026-07-13","updated":"2026-09-18","sourceUpdated":"2026-09-18T02:00:03.605686787Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/PYSEC-2026-2946","references":[{"url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-ffp3-3562-8cv3"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-56074"},{"url":"https://github.com/MervinPraison/PraisonAI"},{"url":"https://github.com/MervinPraison/PraisonAI/releases/tag/v4.5.128"},{"url":"https://www.vulncheck.com/advisories/praisonai-tool-approval-cache-bypass-via-coarse-grained-caching"},{"url":"https://pypi.org/project/praisonaiagents"},{"url":"https://github.com/advisories/GHSA-ffp3-3562-8cv3"}],"tags":["osv","pip"],"epss":0.00166,"epssPercentile":0.06263,"ingestedAt":"2026-07-13T18:57:57.301Z","slug":"CVE-2026-56074","body":"## Overview\n\n## Summary\n\nThe approval system in PraisonAI Agents caches tool approval decisions by tool name only, not by invocation arguments. Once a user approves `execute_command` for any command (e.g., `ls -la`), all subsequent `execute_command` calls in that execution context bypass the approval prompt entirely. Combined with `os.environ.copy()` passing all process environment variables to subprocesses, this allows an LLM agent (potentially via prompt injection) to silently exfiltrate API keys and credentials without further user consent.\n\n## Details\n\nThe `require_approval` decorator in `src/praisonai-agents/praisonaiagents/approval/__init__.py:176-178` checks approval status by tool name only:\n\n```python\n@wraps(func)\ndef wrapper(*args, **kwargs):\n    if is_already_approved(tool_name):   # line 177 — checks only tool_name\n        return func(*args, **kwargs)     # line 178 — bypasses ALL approval\n```\n\nThe `mark_approved` function in `registry.py:144-147` stores only the tool name string:\n\n```python\ndef mark_approved(self, tool_name: str) -> None:\n    approved = self._approved_context.get(set())\n    approved.add(tool_name)              # stores \"execute_command\", not args\n    self._approved_context.set(approved)\n```\n\nThe approval context is never cleared during agent execution — `clear_approved()` exists (`registry.py:152`) but is never called in the agent's tool execution path (`agent/tool_execution.py`).\n\nMeanwhile, the `ConsoleBackend` UI at `backends.py:95-96` misleads the user:\n\n```python\nreturn Confirm.ask(\n    f\"Do you want to execute this {request.risk_level} risk tool?\",\n    # \"this\" implies per-invocation approval\n)\n```\n\nThe UI displays the specific command arguments (lines 81-85), creating a reasonable expectation that the user is approving only that specific invocation.\n\nAdditionally, `shell_tools.py:77` passes the full process environment to every subprocess:\n\n```python\nprocess_env = os.environ.copy()  # includes OPENAI_API_KEY, etc.\n```\n\nThere is no command filtering, blocklist, or environment variable sanitization in the shell tools module.\n\n## PoC\n\n```python\nfrom praisonaiagents import Agent\nfrom praisonaiagents.tools.shell_tools import execute_command\n\n# Step 1: Create agent with shell tool\nagent = Agent(\n    name=\"worker\",\n    instructions=\"You are a helpful assistant.\",\n    tools=[execute_command]\n)\n\n# Step 2: Agent requests benign command — user sees Rich panel:\n#   Function: execute_command\n#   Risk Level: CRITICAL\n#   Arguments:\n#     command: ls -la\n#   \"Do you want to execute this critical risk tool?\" [y/N]\n# User approves → mark_approved(\"execute_command\") is called\n\n# Step 3: All subsequent execute_command calls bypass approval silently:\n# execute_command(command=\"env\")\n#   → returns ALL environment variables (OPENAI_API_KEY, AWS_SECRET_ACCESS_KEY, etc.)\n#   → NO approval prompt shown\n\n# Step 4: Targeted extraction also bypasses approval:\n# execute_command(command=\"printenv OPENAI_API_KEY\")\n#   → returns the specific API key\n#   → NO approval prompt shown\n\n# Verification: check the approval cache\nfrom praisonaiagents.approval import is_already_approved\n# After approving \"ls -la\":\n# is_already_approved(\"execute_command\") → True\n# Any execute_command call now returns immediately at __init__.py:177-178\n```\n\n## Impact\n\n- **Secret exfiltration**: An LLM agent (or one subjected to prompt injection) can dump all process environment variables after a single benign command approval. Common secrets include `OPENAI_API_KEY`, `AWS_SECRET_ACCESS_KEY`, `DATABASE_URL`, and any other credentials passed via environment.\n- **Misleading consent UI**: The console prompt displays specific arguments and uses language (\"this tool\") that implies per-invocation consent, but the system grants session-wide blanket approval.\n- **No expiration or scope**: The approval cache uses a `ContextVar` that persists for the entire agent execution context with no timeout, no command-count limit, and no clearing between tool calls.\n- **No environment filtering**: `os.environ.copy()` passes every environment variable to subprocesses without filtering sensitive patterns.\n\n## Recommended Fix\n\n1. **Per-invocation approval for critical tools** — store a hash of `(tool_name, arguments)` instead of just `tool_name`, or require re-approval for each invocation of critical-risk tools:\n\n```python\n# In registry.py — change mark_approved/is_already_approved:\nimport hashlib, json\n\ndef mark_approved(self, tool_name: str, arguments: dict = None) -> None:\n    approved = self._approved_context.get(set())\n    risk = self._risk_levels.get(tool_name)\n    if risk == \"critical\" and arguments:\n        key = f\"{tool_name}:{hashlib.sha256(json.dumps(arguments, sort_keys=True).encode()).hexdigest()}\"\n    else:\n        key = tool_name\n    approved.add(key)\n    self._approved_context.set(approved)\n\ndef is_already_approved(self, tool_name: str, arguments: dict = None) -> bool:\n    approved = self._approved_context.get(set())\n    risk = self._risk_levels.get(tool_name)\n    if risk == \"critical\" and arguments:\n        key = f\"{tool_name}:{hashlib.sha256(json.dumps(arguments, sort_keys=True).encode()).hexdigest()}\"\n        return key in approved\n    return tool_name in approved\n```\n\n2. **Filter environment variables** in `shell_tools.py`:\n\n```python\nSENSITIVE_PATTERNS = ('_KEY', '_SECRET', '_TOKEN', '_PASSWORD', '_CREDENTIAL')\n\nprocess_env = {\n    k: v for k, v in os.environ.items()\n    if not any(p in k.upper() for p in SENSITIVE_PATTERNS)\n}\nif env:\n    process_env.update(env)\n```\n\n## Affected packages\n\n- `praisonaiagents < 4.5.128`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `praisonaiagents 4.5.128`","depth":"sunlit","depthScore":30,"depthScoreParts":{"impact":30.3,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}