GHSA-CH89-H4R2-C8F8
Vulnerability from github – Published: 2026-08-25 14:54 – Updated: 2026-08-25 14:54Summary
PraisonAI's praisonai.code tool wrappers (exported as CODE_TOOLS for agents) expose a workspace setting that the module itself treats as a path-traversal security boundary — read_file, write_file, apply_diff, and search_replace explicitly call is_path_within_directory() and return "… is outside the workspace" on violations. That boundary is enforced unsoundly and inconsistently:
- The containment helper uses
os.path.abspath(), notrealpath()/Path.resolve(). A symlink located inside the workspace whose target is outside has anabspath()that is still inside the workspace, so it passes the check whileopen()follows the link. This bypasses read, write, apply_diff, and search_replace (CWE-59). list_files()resolvespathagainst the workspace but never calls the containment helper at all —../and absolute paths escape directly (CWE-22).execute_command()takes aworkspaceargument documented "for security validation" but performs nocwdcontainment check;code_execute_command()resolves a relativecwdagainst the workspace and also never validates it (and never even passesworkspaceto the low-level helper). A relativecwd="../outside"runs commands from outside the workspace (CWE-22). An attacker who can influence an agent that has these tools attached (untrusted prompt, indirect prompt injection, or a server-exposed agent) can read, overwrite, list, and execute from outside the configured workspace, bounded only by the process user's filesystem permissions.
Technical Detail
1. Unsound containment helper (symlink bypass — CWE-59)
# src/praisonai/praisonai/code/utils/file_utils.py — is_path_within_directory()
abs_file = os.path.abspath(file_path) # does NOT resolve symlinks
abs_dir = os.path.abspath(directory)
if not abs_dir.endswith(os.sep): abs_dir += os.sep
return abs_file.startswith(abs_dir) or abs_file == abs_dir.rstrip(os.sep)
read_file/write_file/apply_diff/search_replace call this with the configured workspace (e.g. read_file.py: # Security check - ensure path is within workspace). Because abspath() does not canonicalize symlinks, a link at WORKSPACE/link_to_secret.txt → /outside/secret.txt has abspath WORKSPACE/link_to_secret.txt (inside) and passes, while open() follows it to the real outside target.
2. list_files() has no containment check (CWE-22)
# src/praisonai/praisonai/code/tools/list_files.py
if workspace and not os.path.isabs(path):
abs_path = os.path.abspath(os.path.join(workspace, path)) # ../ collapses out of workspace
else:
abs_path = os.path.abspath(path) # absolute path used as-is
# ... os.path.isdir(abs_path) then listed. is_path_within_directory() is NEVER called.
3. execute_command() never validates cwd (CWE-22)
# src/praisonai/praisonai/code/tools/execute_command.py — workspace param doc: "for security validation"
if cwd:
if workspace and not os.path.isabs(cwd):
work_dir = os.path.abspath(os.path.join(workspace, cwd)) # ../ escapes; no containment check
else:
work_dir = os.path.abspath(cwd)
# subprocess.run(args, cwd=work_dir, ...) # no is_path_within_directory() anywhere
# src/praisonai/praisonai/code/agent_tools.py — code_execute_command()
if work_dir and _workspace_root and not os.path.isabs(work_dir):
work_dir = os.path.join(_workspace_root, work_dir) # joins, never validates
result = _execute_command(command=command, cwd=work_dir, timeout=120) # workspace not even passed
Note: execute_command rejects shell=True and runs shlex.split(command) via subprocess.run (no shell), so shell metacharacters (&&, >, pipes) do not work — but any binary still runs with attacker-chosen argv from the escaped cwd, which is sufficient to read/write outside the workspace.
The workspace is an intended boundary (pre-empts "by design")
The module asserts this control itself: read_file.py "Security check - ensure path is within workspace"; write_file.py "default workspace is cwd so relative paths cannot escape"; is_path_within_directory docstring "(prevents path traversal)"; execute_command workspace param "for security validation". The bug is that the asserted control is unsound (abspath vs realpath) and not applied to list_files/execute_command cwd.
Proof of Concept
Self-contained, local temp fixtures only; no network, no untrusted commands. Real praisonai.code agent tools were called.
workspace = /tmp/.../workspace outside = /tmp/.../outside
[1] baseline plain ../ read -> BLOCKED: "Path '../outside/secret.txt' is outside the workspace"
[2] symlink read (in-WS link) -> SUCCESS: returned "SECRET_OUTSIDE_WORKSPACE"
[3] symlink write (in-WS link) -> SUCCESS: outside file now contains "OVERWRITTEN_VIA_SYMLINK"
[4] code_list_files("../outside") -> SUCCESS: "Contents of ../outside: 📄 secret.txt"
[5] code_execute_command(cwd="../outside","pwd") -> SUCCESS: stdout "/tmp/.../outside"
[6] code_execute_command(cwd="../outside", python3 -c open('planted.txt','w')...)
-> SUCCESS: new file created OUTSIDE workspace, "PWNED_OUTSIDE_WORKSPACE"
Steps 2–6 each cross the configured workspace boundary; step 1 shows the plain-../ guard that the symlink and unscoped vectors bypass.
Impact
- Confidentiality: read files outside the workspace (in-workspace symlink; or list/enumerate outside dirs via
list_files). - Integrity: overwrite outside files via symlink; create/modify files outside the workspace via
execute_commandrunning in an escaped cwd. - Execution boundary: run arbitrary available binaries (argv-controlled) from a directory outside the workspace. Bounded by the process user's permissions. In a code-agent or server-exposed agent processing untrusted input, this exposes secrets / project-adjacent / host files and breaks the project-boundary integrity guarantee the workspace setting advertises.
Suggested Fix
- Replace
is_path_within_directory()with arealpath()/Path.resolve()-based containment check, and compare withos.path.commonpath()rather thanstartswith. - Apply that check consistently to every file path, directory path, backup path, diff/search-replace target, and command working directory, after full canonicalization (resolve the symlink's real target, not the link path).
list_files(): reject absolute paths and../escapes whenworkspaceis set.execute_command(): validatecwdcontainment whenworkspaceis set;code_execute_command()should pass_workspace_rootto the low-level helper or validate itself.- Regression tests: symlink read/write/diff/search-replace to outside targets;
list_files("../outside", workspace=…);execute_command(cwd="../outside", workspace=…); absolute outside paths with a workspace set.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "PraisonAI"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.6.58"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-55540"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-25T14:54:56Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\nPraisonAI\u0027s `praisonai.code` tool wrappers (exported as `CODE_TOOLS` for agents) expose a `workspace` setting that the module itself treats as a path-traversal **security boundary** \u2014 `read_file`, `write_file`, `apply_diff`, and `search_replace` explicitly call `is_path_within_directory()` and return `\"\u2026 is outside the workspace\"` on violations. That boundary is enforced **unsoundly and inconsistently**:\n \n1. The containment helper uses `os.path.abspath()`, not `realpath()`/`Path.resolve()`. A symlink located **inside** the workspace whose target is **outside** has an `abspath()` that is still inside the workspace, so it passes the check while `open()` follows the link. This bypasses read, write, apply_diff, and search_replace (CWE-59).\n2. `list_files()` resolves `path` against the workspace but **never** calls the containment helper at all \u2014 `../` and absolute paths escape directly (CWE-22).\n3. `execute_command()` takes a `workspace` argument documented \"for security validation\" but performs **no** `cwd` containment check; `code_execute_command()` resolves a relative `cwd` against the workspace and also never validates it (and never even passes `workspace` to the low-level helper). A relative `cwd=\"../outside\"` runs commands from outside the workspace (CWE-22).\nAn attacker who can influence an agent that has these tools attached (untrusted prompt, indirect prompt injection, or a server-exposed agent) can read, overwrite, list, and execute from outside the configured workspace, bounded only by the process user\u0027s filesystem permissions.\n\n## Technical Detail\n \n### 1. Unsound containment helper (symlink bypass \u2014 CWE-59)\n \n```python\n# src/praisonai/praisonai/code/utils/file_utils.py \u2014 is_path_within_directory()\nabs_file = os.path.abspath(file_path) # does NOT resolve symlinks\nabs_dir = os.path.abspath(directory)\nif not abs_dir.endswith(os.sep): abs_dir += os.sep\nreturn abs_file.startswith(abs_dir) or abs_file == abs_dir.rstrip(os.sep)\n```\n \n`read_file`/`write_file`/`apply_diff`/`search_replace` call this with the configured workspace (e.g. `read_file.py`: `# Security check - ensure path is within workspace`). Because `abspath()` does not canonicalize symlinks, a link at `WORKSPACE/link_to_secret.txt` \u2192 `/outside/secret.txt` has `abspath` `WORKSPACE/link_to_secret.txt` (inside) and passes, while `open()` follows it to the real outside target.\n \n### 2. `list_files()` has no containment check (CWE-22)\n \n```python\n# src/praisonai/praisonai/code/tools/list_files.py\nif workspace and not os.path.isabs(path):\n abs_path = os.path.abspath(os.path.join(workspace, path)) # ../ collapses out of workspace\nelse:\n abs_path = os.path.abspath(path) # absolute path used as-is\n# ... os.path.isdir(abs_path) then listed. is_path_within_directory() is NEVER called.\n```\n \n### 3. `execute_command()` never validates `cwd` (CWE-22)\n \n```python\n# src/praisonai/praisonai/code/tools/execute_command.py \u2014 workspace param doc: \"for security validation\"\nif cwd:\n if workspace and not os.path.isabs(cwd):\n work_dir = os.path.abspath(os.path.join(workspace, cwd)) # ../ escapes; no containment check\n else:\n work_dir = os.path.abspath(cwd)\n# subprocess.run(args, cwd=work_dir, ...) # no is_path_within_directory() anywhere\n```\n \n```python\n# src/praisonai/praisonai/code/agent_tools.py \u2014 code_execute_command()\nif work_dir and _workspace_root and not os.path.isabs(work_dir):\n work_dir = os.path.join(_workspace_root, work_dir) # joins, never validates\nresult = _execute_command(command=command, cwd=work_dir, timeout=120) # workspace not even passed\n```\n \nNote: `execute_command` rejects `shell=True` and runs `shlex.split(command)` via `subprocess.run` (no shell), so shell metacharacters (`\u0026\u0026`, `\u003e`, pipes) do not work \u2014 but any binary still runs with attacker-chosen argv **from the escaped cwd**, which is sufficient to read/write outside the workspace.\n \n### The workspace is an intended boundary (pre-empts \"by design\")\n \nThe module asserts this control itself: `read_file.py` \"Security check - ensure path is within workspace\"; `write_file.py` \"default workspace is cwd so relative paths cannot escape\"; `is_path_within_directory` docstring \"(prevents path traversal)\"; `execute_command` `workspace` param \"for security validation\". The bug is that the asserted control is unsound (abspath vs realpath) and not applied to `list_files`/`execute_command` cwd.\n\n## Proof of Concept\n \nSelf-contained, local temp fixtures only; no network, no untrusted commands. Real `praisonai.code` agent tools were called.\n \n```\nworkspace = /tmp/.../workspace outside = /tmp/.../outside\n[1] baseline plain ../ read -\u003e BLOCKED: \"Path \u0027../outside/secret.txt\u0027 is outside the workspace\"\n[2] symlink read (in-WS link) -\u003e SUCCESS: returned \"SECRET_OUTSIDE_WORKSPACE\"\n[3] symlink write (in-WS link) -\u003e SUCCESS: outside file now contains \"OVERWRITTEN_VIA_SYMLINK\"\n[4] code_list_files(\"../outside\") -\u003e SUCCESS: \"Contents of ../outside: \ud83d\udcc4 secret.txt\"\n[5] code_execute_command(cwd=\"../outside\",\"pwd\") -\u003e SUCCESS: stdout \"/tmp/.../outside\"\n[6] code_execute_command(cwd=\"../outside\", python3 -c open(\u0027planted.txt\u0027,\u0027w\u0027)...)\n -\u003e SUCCESS: new file created OUTSIDE workspace, \"PWNED_OUTSIDE_WORKSPACE\"\n```\n \nSteps 2\u20136 each cross the configured workspace boundary; step 1 shows the plain-`../` guard that the symlink and unscoped vectors bypass.\n \n## Impact\n \n- **Confidentiality**: read files outside the workspace (in-workspace symlink; or list/enumerate outside dirs via `list_files`).\n- **Integrity**: overwrite outside files via symlink; create/modify files outside the workspace via `execute_command` running in an escaped cwd.\n- **Execution boundary**: run arbitrary available binaries (argv-controlled) from a directory outside the workspace.\nBounded by the process user\u0027s permissions. In a code-agent or server-exposed agent processing untrusted input, this exposes secrets / project-adjacent / host files and breaks the project-boundary integrity guarantee the workspace setting advertises.\n\n## Suggested Fix\n \n- Replace `is_path_within_directory()` with a `realpath()` / `Path.resolve()`-based containment check, and compare with `os.path.commonpath()` rather than `startswith`.\n- Apply that check consistently to every file path, directory path, backup path, diff/search-replace target, **and command working directory**, after full canonicalization (resolve the symlink\u0027s real target, not the link path).\n- `list_files()`: reject absolute paths and `../` escapes when `workspace` is set.\n- `execute_command()`: validate `cwd` containment when `workspace` is set; `code_execute_command()` should pass `_workspace_root` to the low-level helper or validate itself.\n- Regression tests: symlink read/write/diff/search-replace to outside targets; `list_files(\"../outside\", workspace=\u2026)`; `execute_command(cwd=\"../outside\", workspace=\u2026)`; absolute outside paths with a workspace set.",
"id": "GHSA-ch89-h4r2-c8f8",
"modified": "2026-08-25T14:54:56Z",
"published": "2026-08-25T14:54:56Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-ch89-h4r2-c8f8"
},
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/commit/2f9677abb2ea68eab864ee8b6a828fd0141612e1"
},
{
"type": "PACKAGE",
"url": "https://github.com/MervinPraison/PraisonAI"
},
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.58"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:L",
"type": "CVSS_V3"
}
],
"summary": "PraisonAI: [Path Traversal] agent tools escape the configured workspace via symlinks"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.