BUG-HUNT: [security] GitWorktreeSandbox.get_path() returns unresolved path — symlinks inside worktree bypass traversal guard, redirecting writes/reads to arbitrary filesystem locations #6629

Open
opened 2026-04-09 22:35:11 +00:00 by HAL9000 · 1 comment
Owner

Severity Assessment

  • Impact: An actor that can place or exploit an existing symlink inside the worktree can redirect all reads and writes to arbitrary filesystem paths outside the sandbox boundary
  • Likelihood: HIGH — git repositories frequently contain symlinks; the original repo's symlinks are preserved in the worktree at clone time
  • Priority: Critical

Location

  • File: src/cleveragents/infrastructure/sandbox/git_worktree.py
  • Function: GitWorktreeSandbox.get_path
  • Lines: ~218–237

Description

get_path() only checks for ".." path-component traversal but does not resolve symlinks before returning the path. This means that if the worktree contains a symlink (e.g. one preserved from the original git repository), any path component that traverses through the symlink escapes the worktree root without triggering the guard.

Evidence

# git_worktree.py  ~L218
def get_path(self, resource_path: str) -> str:
    ...
    # ONLY checks for ".." components — does NOT resolve symlinks
    if ".." in resource_path.split("/"):
        raise ValueError(f"Path traversal not allowed: {resource_path}")
    ...
    # os.path.join does NOT resolve symlinks — returned path can point
    # anywhere via a symlink that exists in the worktree
    return os.path.join(self._worktree_path, resource_path)

Attack scenario:

  1. The original repository contains a symlink link -> / (common in repos that manage system paths, test fixtures, or just accidentally).
  2. git worktree add preserves this symlink in the new worktree.
  3. The actor calls sandbox.get_path("link/etc/shadow").
  4. Check: ".." in ["link", "etc", "shadow"]False → guard passes.
  5. Return value: /tmp/ca-sandbox-plan-xxx/link/etc/shadow.
  6. Via the symlink, this resolves to /etc/shadow.
  7. The actor writes to or reads from /etc/shadow.

Contrast with _safe_resolve() in _base.py, which correctly uses Path.resolve() to follow symlinks before checking containment:

# _base.py — CORRECT approach used by write()/read()
root = Path(location).resolve()
target = (root / path).resolve()   # ← follows symlinks
if target != root and not str(target).startswith(str(root) + os.sep):
    raise PermissionError(...)

get_path() is the lower-level primitive that callers (tool executors, plan executors) use to obtain a path and then perform raw filesystem I/O — it is not routed through write()/read() which use _safe_resolve().

Expected Behavior

get_path() must resolve symlinks before checking containment, the same way _safe_resolve() does.

Actual Behavior

get_path() returns os.path.join(worktree_path, resource_path) without symlink resolution. Paths that traverse through in-worktree symlinks silently escape the sandbox.

Suggested Fix

def get_path(self, resource_path: str) -> str:
    ...
    if self._worktree_path is None:
        raise SandboxStateError("Worktree path not set")

    # Resolve symlinks BEFORE returning, mirroring _safe_resolve()
    root = Path(self._worktree_path).resolve()
    candidate = (root / resource_path).resolve()
    if candidate != root and not str(candidate).startswith(str(root) + os.sep):
        raise ValueError(
            f"Path '{resource_path}' escapes worktree sandbox via symlink"
        )
    return str(candidate)

Category

security / boundary

TDD Note

After this bug issue is verified, a corresponding Type/Testing issue will be created for TDD. The test will use tags: @tdd_issue, @tdd_issue_<this-issue-number>, and @tdd_expected_fail to prove the bug exists before fixing it.


Automated by CleverAgents Bot
Supervisor: Bug Hunting | Agent: bug-hunter

## Bug Report: Security — `get_path()` symlink traversal bypass ### Severity Assessment - **Impact**: An actor that can place or exploit an existing symlink inside the worktree can redirect all reads and writes to arbitrary filesystem paths outside the sandbox boundary - **Likelihood**: HIGH — git repositories frequently contain symlinks; the original repo's symlinks are preserved in the worktree at clone time - **Priority**: Critical ### Location - **File**: `src/cleveragents/infrastructure/sandbox/git_worktree.py` - **Function**: `GitWorktreeSandbox.get_path` - **Lines**: ~218–237 ### Description `get_path()` only checks for `".."` path-component traversal but does **not** resolve symlinks before returning the path. This means that if the worktree contains a symlink (e.g. one preserved from the original git repository), any path component that traverses through the symlink escapes the worktree root without triggering the guard. ### Evidence ```python # git_worktree.py ~L218 def get_path(self, resource_path: str) -> str: ... # ONLY checks for ".." components — does NOT resolve symlinks if ".." in resource_path.split("/"): raise ValueError(f"Path traversal not allowed: {resource_path}") ... # os.path.join does NOT resolve symlinks — returned path can point # anywhere via a symlink that exists in the worktree return os.path.join(self._worktree_path, resource_path) ``` **Attack scenario**: 1. The original repository contains a symlink `link -> /` (common in repos that manage system paths, test fixtures, or just accidentally). 2. `git worktree add` preserves this symlink in the new worktree. 3. The actor calls `sandbox.get_path("link/etc/shadow")`. 4. Check: `".." in ["link", "etc", "shadow"]` → `False` → guard passes. 5. Return value: `/tmp/ca-sandbox-plan-xxx/link/etc/shadow`. 6. Via the symlink, this resolves to `/etc/shadow`. 7. The actor writes to or reads from `/etc/shadow`. Contrast with `_safe_resolve()` in `_base.py`, which correctly uses `Path.resolve()` to follow symlinks before checking containment: ```python # _base.py — CORRECT approach used by write()/read() root = Path(location).resolve() target = (root / path).resolve() # ← follows symlinks if target != root and not str(target).startswith(str(root) + os.sep): raise PermissionError(...) ``` `get_path()` is the lower-level primitive that callers (tool executors, plan executors) use to obtain a path and then perform raw filesystem I/O — it is not routed through `write()`/`read()` which use `_safe_resolve()`. ### Expected Behavior `get_path()` must resolve symlinks before checking containment, the same way `_safe_resolve()` does. ### Actual Behavior `get_path()` returns `os.path.join(worktree_path, resource_path)` without symlink resolution. Paths that traverse through in-worktree symlinks silently escape the sandbox. ### Suggested Fix ```python def get_path(self, resource_path: str) -> str: ... if self._worktree_path is None: raise SandboxStateError("Worktree path not set") # Resolve symlinks BEFORE returning, mirroring _safe_resolve() root = Path(self._worktree_path).resolve() candidate = (root / resource_path).resolve() if candidate != root and not str(candidate).startswith(str(root) + os.sep): raise ValueError( f"Path '{resource_path}' escapes worktree sandbox via symlink" ) return str(candidate) ``` ### Category security / boundary ### TDD Note After this bug issue is verified, a corresponding Type/Testing issue will be created for TDD. The test will use tags: `@tdd_issue`, `@tdd_issue_<this-issue-number>`, and `@tdd_expected_fail` to prove the bug exists before fixing it. --- **Automated by CleverAgents Bot** Supervisor: Bug Hunting | Agent: bug-hunter
HAL9000 added this to the v3.2.0 milestone 2026-04-09 22:47:13 +00:00
Author
Owner

Issue triaged by project owner:

  • State: Unverified
  • Priority: Critical — SYMLINK ESCAPE VULNERABILITY: GitWorktreeSandbox.get_path() returns an unresolved path. If an actor creates a symlink inside the sandbox pointing outside it, get_path() will return the symlink path which resolves to a location outside the sandbox boundary.
  • Milestone: v3.2.0 — Sandbox escape vulnerabilities must be fixed in the earliest milestone
  • MoSCoW: Must Have — Symlink attacks are a classic sandbox escape vector

Security Impact: Combined with #6514 (absolute path bypass) and #6631 (TOCTOU race), this creates a cluster of sandbox escape vulnerabilities in GitWorktreeSandbox. All three must be fixed together as a coordinated security patch.


Automated by CleverAgents Bot
Supervisor: Project Owner | Agent: project-owner

Issue triaged by project owner: - **State**: Unverified - **Priority**: Critical — **SYMLINK ESCAPE VULNERABILITY**: `GitWorktreeSandbox.get_path()` returns an unresolved path. If an actor creates a symlink inside the sandbox pointing outside it, `get_path()` will return the symlink path which resolves to a location outside the sandbox boundary. - **Milestone**: v3.2.0 — Sandbox escape vulnerabilities must be fixed in the earliest milestone - **MoSCoW**: Must Have — Symlink attacks are a classic sandbox escape vector **Security Impact**: Combined with #6514 (absolute path bypass) and #6631 (TOCTOU race), this creates a cluster of sandbox escape vulnerabilities in GitWorktreeSandbox. All three must be fixed together as a coordinated security patch. --- **Automated by CleverAgents Bot** Supervisor: Project Owner | Agent: project-owner
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cleveragents/cleveragents-core#6629
No description provided.