---
id: "CVE-2026-61438"
kind: "vulnerability"
aliases: ["CVE-2026-61438", "GHSA-26mh-57q7-jfvr"]
title: "PraisonAI vulnerable to Remote Code Execution via Broken AST Sandbox"
severity: "high"
cvss: 7.3
epss: 0.0031
exploited: false
status: "open"
category: "ai-tooling"
products: [{"name": "praisonai", "fixed": ["4.6.78"], "affected": "< 4.6.78", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ai-agents", "ai-tooling", "ci", "cwe-184", "cwe-693", "cwe-78", "praisonai", "python"]
not_affected_if: ["default-config", "trusted-workflows-only"]
published_at: "2026-07-15T12:18:18+00:00"
first_seen_at: "2026-10-29T16:39:03+00:00"
updated_at: "2026-10-29T16:56:32+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-26mh-57q7-jfvr", "first_seen_at": "2026-10-29T16:39:03+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-61438", "first_seen_at": "2026-10-29T16:56:32+00:00"}]
---

# PraisonAI vulnerable to Remote Code Execution via Broken AST Sandbox

PraisonAI's inline Python step in YAML job workflows checks code with an AST allowlist that permits importing the os module, so a script can import os and call os.system. An attacker who supplies or contributes a workflow file can run arbitrary commands with the privileges of the process that runs it.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:praisonai affected < 4.6.78 fixed 4.6.78

## How to check

- Check the installed version with `pip show praisonai`; versions below `4.6.78` are affected.
- Search workflow YAML files for `type: job` with `script:` steps containing `import os`.
- Check whether `PRAISONAI_ALLOW_JOB_WORKFLOWS` is set to `true` in CI or runtime environments.

## Fix

- `praisonai` (PyPI): move to `4.6.78` or later.

## Timeline

- 2026-10-29T16:39:03+00:00 ghsa https://github.com/advisories/GHSA-26mh-57q7-jfvr
- 2026-10-29T16:56:32+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-61438

## Documents

- [advisory] Maintainer's advisory: api.github.com https://api.github.com/repos/MervinPraison/PraisonAI/security-advisories/GHSA-26mh-57q7-jfvr
- [advisory] Maintainer's advisory: github.com https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-26mh-57q7-jfvr

## From the sources

### Summary
JobWorkflowExecutor._exec_inline_python() is designed to safely execute inline Python expressions from YAML workflow script: steps using AST validation. The function claims to block dangerous operations by (a) checking all AST nodes against an allowlist, and (b) providing a __builtins__ whitelist that omits __import__, exec, eval, and open.

The sandbox is broken. The module import allowlist (allowed_modules) explicitly includes 'os', and the AST allowlist permits ast.Import, ast.ImportFrom, ast.Call, and ast.Attribute nodes. This means the following malicious workflow script: step passes all AST validation checks and executes without error.

Attack Scenario

Attacker creates or contributes a YAML workflow file (type: job) with a script: step containing import os; os.system(...).
A user or CI pipeline with PRAISONAI_ALLOW_JOB_WORKFLOWS=true executes the workflow via praisonai workflow run malicious.yaml.
Arbitrary OS commands execute with the privileges of the calling process.


This is particularly dangerous in multi-tenant or CI environments where YAML workflows may originate from untrusted sources (e.g., downloaded templates, shared repositories, AI-generated workflows).
### PoC
_Complete instructions, including specific configuration details, to reproduce the vulnerability._

### Impact

Root Cause — Lines 364–372:

pythonallowed_modules = {'os', 'datetime', 'time', 'json', 'math', 're', 'collections', 'itertools'}
if isinstance(node, ast.Import):
    for alias in node.names:
        if alias.name.split('.')[0] not in allowed_modules:
            return {"ok": False, "error": f"Import of '{alias.name}' not allowed..."}

os is trusted because it is commonly used for path operations (os.path, os.getcwd). However, os.system(), os.popen(), os.execvp(), os.spawn*(), and os._exit() are all accessible once the module is imported, none of which are individually blocked.

The __builtins__ restriction (blocking __import__) is irrelevant because the code uses a real import statement processed by the Python bytecode compiler — not a __import__() call.

Proof-of-Concept validation (static — no remote network call made):

pythonimport ast

_ALLOWED_NODES = (
    ast.Expression, ast.BoolOp, ast.BinOp, ast.UnaryOp, ast.Compare,
    ast.IfExp, ast.Constant, ast.Name, ast.Load, ast.Store, ast.Subscript,
    ast.List, ast.Tuple, ast.Dict, ast.Set,
    ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Lt, ast.LtE,
    ast.Gt, ast.GtE, ast.In, ast.NotIn, ast.Add, ast.Sub, ast.Mult,
    ast.Div, ast.Mod, ast.FloorDiv, ast.USub, ast.UAdd,
    ast.Call, ast.Attribute, ast.keyword, ast.Starred,
    ast.Module, ast.Assign, ast.Import, ast.ImportFrom, ast.alias,
)
allowed_modules = {'os', 'datetime', 'time', 'json', 'math', 're', 'collections', 'itertools'}

malicious = "import os\nresult = os.system('id')"
tree = ast.parse(malicious, mode="exec")

all_pass = True
for node in ast.walk(tree):
    if not isinstance(node, _ALLOWED_NODES):
        print(f"BLOCKED: {type(node).__name__}")
        all_pass = False
    if isinstance(node, ast.Import):
        for alias in node.names:
            if alias.name.split('.')[0] not in allowed_modules:
                print(f"IMPORT BLOCKED: {alias.name}")
                all_pass = False

print("SANDBOX BYPASSED" if all_pass else "sandbox holds")
# Output: SANDBOX BYPASSED


Recommended Fix

Remove 'os' from allowed_modules and replace it with only the safe submodule: 'os.path'. Then validate that the imported name is specifically os.path, not os:

python# BEFORE (vulnerable)
allowed_modules = {'os', 'datetime', 'time', 'json', 'math', 're', 'collections', 'itertools'}

# AFTER (safe)
allowed_modules = {'datetime', 'time', 'json', 'math', 're', 'collections', 'itertools', 'pathlib'}

# If os.path is needed, handle it explicitly:
if isinstance(node, ast.ImportFrom):
    if node.module == 'os.path':
        pass  # allow 'from os.path import join, exists' only
    elif node.module and node.module.split('.')[0] not in allowed_modules:
        return {"ok": False, "error": f"Import of '{node.module}' not allowed."}

Additionally, block ast.Import of os entirely, and consider also removing re (which can be used for ReDoS/CPU exhaustion) and collections (which can be used to reach __subclasses__-style gadgets).

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
