Filesystem Sandboxing

Purpose

Restrict file-system operations (read, write, execute) to a designated working directory. Prevents directory traversal attacks, where a caller supplies a path like ../../etc/passwd to escape the intended boundary.

Common context: tool functions exposed to an LLM agent, where the model (or a malicious prompt injection) could supply arbitrary paths.

Architecture

User-supplied path  (e.g., "../../secrets/key.txt")
        │
        ▼
abs_working_dir = os.path.abspath(working_directory)
full_path       = os.path.normpath(os.path.join(abs_working_dir, user_path))
full_path_abs   = os.path.abspath(full_path)
        │
        ▼
if os.path.commonpath([abs_working_dir, full_path_abs]) != abs_working_dir:
    raise ValueError("Path is outside permitted working directory")
        │
        ▼
  Safe to use full_path_abs

Implementation Notes

Canonical pattern

import os
 
def resolve_safe_path(working_directory: str, user_path: str) -> str:
    """Return absolute path if inside working_directory, else raise."""
    abs_wd = os.path.abspath(working_directory)
    candidate = os.path.abspath(
        os.path.normpath(os.path.join(abs_wd, user_path))
    )
    if os.path.commonpath([abs_wd, candidate]) != abs_wd:
        raise ValueError(
            f'Path "{user_path}" is outside the permitted working directory'
        )
    return candidate

Why both normpath and abspath?

  • normpath collapses ../ sequences and redundant separators in the string representation.
  • abspath resolves remaining relative segments against the real CWD, producing a fully canonical path.
  • Using only one leaves edge cases: normpath alone doesn’t resolve CWD-relative paths; abspath alone doesn’t collapse embedded ../.

commonpath vs. startswith

# Fragile: "/tmp/work2/evil" starts with "/tmp/work"
if candidate.startswith(abs_wd):       # BUG
 
# Correct: commonpath returns the longest shared ancestor
if os.path.commonpath([abs_wd, candidate]) == abs_wd:  # OK

Additional guards per operation

OperationExtra check
Reados.path.isfile(path)
Writenot os.path.isdir(path)
Executepath.endswith(".py") + file existence
Listos.path.isdir(path)

Return errors to callers, don’t raise (LLM tool context)

When used inside an LLM tool function, catch ValueError and return a structured error dict so the model can reason about the failure:

try:
    safe = resolve_safe_path(wd, user_path)
except ValueError as e:
    return {"error": str(e)}

Trade-offs

ApproachProCon
commonpath (above)Correct; pure Python; no privileges neededApplication-level only; symlinks can bypass
chroot / seccomp (OS-level)Kernel-enforced; strongest isolationRequires elevated privileges; complex setup
Container / VM sandboxFull process isolationHeavy; impractical for lightweight tool functions
Allowlist of permitted pathsExplicit controlMaintenance burden; too restrictive for dynamic use

References