Skip to content

[Security] Path traversal via absolute paths bypasses "Security Sandbox" in filesystem.py and multi_project.py #3

Description

@Edneam

Summary

The README's "Security Architecture" section claims filesystem.py provides a "Path safety check (blocks .. traversal)" and that multi_project.py uses "the same policy." In practice, both checks only reject relative ../ segments. An absolute path bypasses the check entirely in filesystem.py, and multi_project.py's cross-repo file tools have no path check at all. Any MCP client (or content an agent is tricked into acting on, e.g. prompt injection from a scraped page or malicious file) can read/write arbitrary files outside the intended project directory.

Affected files: filesystem.py, multi_project.py
Disclosure: Private vulnerability reporting is disabled on this repo (confirmed via gh api repos/hackerlibs/code-hacker/private-vulnerability-reporting{"enabled":false}), so filing directly as a public issue.

Bug 1: filesystem.py::is_safe_path() — absolute-path bypass

def is_safe_path(path: str) -> bool:
    """Check if the path is safe (no directory traversal)."""
    try:
        resolved_path = Path(path).resolve()
        return not any(part.startswith('..') for part in Path(path).parts)
    except Exception:
        return False

resolved_path is computed but never used — the actual check runs against Path(path).parts (the unresolved input), which only ever contains '..' for relative traversal syntax. An absolute path has no .. parts, so it passes unconditionally:

>>> is_safe_path("../../etc/passwd")
False   # blocked, as intended
>>> is_safe_path("/etc/passwd")
True    # NOT blocked — absolute path bypasses the check entirely
>>> is_safe_path("/home/otheruser/.ssh/id_rsa")
True

This function gates 9 of the 12 filesystem-command tools, including read_file, write_file, append_file, edit_file, list_directory, create_directory, find_files, search_files_ag, and the working_directory parameter of execute_command. Every one of them accepts an absolute path straight through to disk with no containment check against any project root.

Suggested fix

Actually use the resolved path, and check containment against an intended base directory:

def is_safe_path(path: str, base_dir: Path | None = None) -> bool:
    try:
        resolved = Path(path).resolve()
    except Exception:
        return False
    if base_dir is None:
        return True  # no boundary configured — document this explicitly if intentional
    try:
        resolved.relative_to(base_dir.resolve())
        return True
    except ValueError:
        return False

If the intent is genuinely "full filesystem access, just block .. cosmetically," the README's "Security Sandbox" claim should be corrected instead — right now it overstates what the check does.

Bug 2: multi_project.py — no path check at all on cross-repo file tools

workspace_read_file, workspace_write_file, and workspace_edit_file build the target path as:

full_path = Path(project_root) / file_path

with no call to is_safe_path or any other validation. Path.joinpath (/) discards the left side entirely when the right side is absolute:

>>> Path("/home/edneam/some_registered_project") / "/etc/shadow"
PosixPath('/etc/shadow')

So workspace_read_file(project="<any-registered-alias>", file_path="/etc/shadow") reads /etc/shadow directly — no .. needed.

It's worse than that, though: _resolve_project_path() doesn't require the project to be pre-registered in the workspace at all —

def _resolve_project_path(ws: dict, name_or_path: str) -> Optional[str]:
    if name_or_path in ws["projects"]:
        return ws["projects"][name_or_path]["path"]
    if Path(name_or_path).is_dir():
        return str(Path(name_or_path).resolve())
    return None

— any existing directory string is accepted as project. So the minimal call is:

workspace_read_file(project="/", file_path="etc/shadow")

with zero setup and zero traversal syntax. workspace_write_file/workspace_edit_file are constrained somewhat by _is_allowed_file()'s extension allowlist (which still includes .env, .yaml, .json, .sh, etc.), but workspace_read_file has no extension check at all — any file the server process can read is exposed (SSH keys, cloud credential files, /etc/shadow if running as root, other users' home directories, etc).

Suggested fix

Require project to resolve via a registered alias only (drop the raw-directory fallback, or make it explicit/opt-in), and validate file_path the same way as Bug 1 — resolve Path(project_root) / file_path, then verify relative_to(project_root) before touching disk. Apply this to workspace_read_file as well, not just the write paths.

Why this matters for this specific project

These servers are designed to be driven by an LLM agent (VS Code Copilot Chat / DeepAgent / TUI), often on content the agent didn't choose — a fetched web page, a file in the repo it's reviewing, output from another tool. The README's own threat model (Security Sandbox — Each server has independent security policies (path checks, ...)) frames this as a boundary against exactly that: a compromised/injected instruction shouldn't be able to walk the agent outside the project it's supposed to be working in. Right now that boundary only blocks the most naive traversal syntax and is trivially sidestepped with an absolute path.

Environment

  • Reviewed against master @ current HEAD (2026-07-28 update)
  • No live server needed to reproduce — is_safe_path() and the Path join behavior are pure functions, reproducible directly:
from pathlib import Path

def is_safe_path(path: str) -> bool:
    resolved_path = Path(path).resolve()
    return not any(part.startswith('..') for part in Path(path).parts)

assert is_safe_path("/etc/passwd") is True  # should be False

full_path = Path("/home/user/some_project") / "/etc/shadow"
assert str(full_path) == "/etc/shadow"  # workspace_read_file target, no containment check

Happy to send a PR with the fix for both files if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions