Pi Web File Access Control and Security: A Deep Dive into the Whitelist Architecture

Pi Web implements file access control through a runtime-built whitelist of allowed directory roots, combining lexical path containment with symlink-aware real-path verification to prevent directory traversal attacks.

Pi Web is a Next.js-based development environment that deliberately restricts filesystem interactions to explicitly permitted directories. This article examines the security architecture implemented in agegr/pi-web, analyzing how the whitelist system works, how it defends against path traversal and symlink attacks, and how API routes enforce these boundaries on every file operation.

Building the Whitelist with getAllowedFileRoots()

The foundation of Pi Web's security model lives in lib/file-access.ts. The getAllowedFileRoots() function dynamically constructs a Set<string> of permitted directory roots from multiple sources:

// lib/file-access.ts
export async function getAllowedFileRoots(): Promise<Set<string>> {
  // Cache for 5 s to avoid rescanning all sessions on every request
  const now = Date.now();
  const cached = globalThis.__piAllowedRootsCache;
  if (cached && cached.expiresAt > now) return cached.roots;

  const sessions = await listAllSessions();           // all *.jsonl files
  const roots = new Set<string>();
  for (const s of sessions) {
    if (s.cwd) roots.add(normalizeSlashes(s.cwd));
    if (s.projectRoot) roots.add(normalizeSlashes(s.projectRoot));
  }

  // Add ~/pi-cwd-YYYYMMDD folders (default-cwd endpoint)
  try {
    for (const name of readdirSync(homedir())) {
      if (/^pi-cwd-\d{8}$/.test(name))
        roots.add(normalizeSlashes(path.join(homedir(), name)));
    }
  } catch {}

  // User-added roots (e.g., after a successful worktree creation)
  for (const root of getAdditionalAllowedRoots()) roots.add(root);

  globalThis.__piAllowedRootsCache = { roots, expiresAt: now + 5_000 };
  return roots;
}

The whitelist aggregates four distinct sources:

  • Session directories: Each Pi session's cwd and projectRoot from stored session files
  • Default working directories: Auto-created ~/pi-cwd-YYYYMMDD folders
  • Runtime additions: Extra roots added via allowFileRoot() API calls
  • Manual extensions: Roots injected through UI or API interactions

The 5-second TTL cache stored on globalThis.__piAllowedRootsCache ensures performance while surviving Next.js hot-reloads during development.

Path Normalization and Cross-Platform Consistency

Before any security checks execute, all paths undergo slash normalization through normalizeSlashes() in lib/allowed-roots.ts. This function converts path separators to forward slashes (/) regardless of host operating system, ensuring that Set membership tests work consistently across Windows, macOS, and Linux.

Normalization eliminates a common source of security bypasses where /allowed/path and \allowed\path might be treated as different strings even when referring to the same directory.

Lexical Containment with isPathWithinRoots()

The primary security gate lives in lib/path-security.ts. The isPathWithinRoots() function performs fast, zero-filesystem-access containment checks:

// lib/path-security.ts
export function isPathWithinRoots(target: string, roots: Set<string>): boolean {
  for (const root of roots) {
    const useWindows = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
    const resolver = useWindows ? path.win32 : path;
    const sep = useWindows ? "\\" : path.sep;
    const normalized = resolver.resolve(target);
    const normalizedRoot = resolver.resolve(root);
    const comparable = useWindows ? normalized.toLowerCase() : normalized;
    const comparableRoot = useWindows ? normalizedRoot.toLowerCase() : normalizedRoot;
    const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
    if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) return true;
  }
  return false;
}

This implementation handles several critical edge cases:

  • Platform detection: Uses path.win32 resolver when either path is Windows-absolute
  • Case insensitivity: Lowercases both paths on Windows before comparison
  • Directory boundary enforcement: Appends separator to root and verifies startsWith, preventing /allowed/dir-evil from matching /allowed/dir

The lexical check alone stops obvious traversal attacks like ../../../etc/passwd without touching the filesystem.

Lexical containment is insufficient when symlinks enter the picture. A malicious symlink placed inside an allowed root could point anywhere on the filesystem. Pi Web addresses this with isExistingPathWithinRoots():

export function isExistingPathWithinRoots(target: string, roots: Set<string>): boolean {
  let realTarget: string;
  try { realTarget = realpathSync(target); } catch { return false; }

  const realRoots = new Set<string>();
  for (const root of roots) {
    try { realRoots.add(realpathSync(root)); } catch {}   // ignore stale roots
  }
  return isPathWithinRoots(realTarget, realRoots);
}

The defense strategy resolves both the target path and all whitelist roots through realpathSync, converting symbolic links to their actual filesystem locations. Only then does the containment check apply. This two-phase approach—lexical first, real-path second—balances performance with security: paths that fail the cheap lexical test are rejected immediately, while actual file operations pay the cost of symlink resolution.

Public API Security Surface

lib/file-access.ts exports two security functions that API routes consume:

export function isFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
  // lexical check – no FS hit
  return isPathWithinRoots(target, allowedRoots);
}

export function isExistingFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
  // resolves symlinks, then lexical check
  return isExistingPathWithinRoots(target, allowedRoots);
}

Every filesystem-exposing endpoint—including /api/files/*, /api/git/*, /api/worktrees, and plugin routes—follows this enforcement pattern:

  1. Fetch current whitelist: await getAllowedFileRoots()
  2. Lexical validation: isFilePathAllowed() for path-only decisions
  3. Real-path validation: isExistingFilePathAllowed() before any read/write
  4. HTTP 403 response with { error: "Access denied" } on any failure

File Read Security in Practice

The app/api/files/[...path]/route.ts endpoint demonstrates the layered validation strategy:

// app/api/files/[...path]/route.ts – GET handler
const allowedRoots = await getAllowedFileRoots();
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
const allowedBySessionReference = 
    !allowedByRoot && type !== "list" && await isFilePathReferencedBySession(filePath, sessionId);

if (!allowedByRoot && !allowedBySessionReference) {
  return NextResponse.json({ error: "Access denied" }, { status: 403 });
}

// Resolve symlinks for existing files
if (!isExistingFilePathAllowed(existingAuthorizationPath, allowedRoots)) {
  return NextResponse.json({ error: "Access denied" }, { status: 403 });
}

This reveals Pi Web's dual authorization paths:

  • Root whitelist: Standard containment within configured directories
  • Session reference: Explicit attachment references that grant access regardless of location

Both paths ultimately require the real-path check before filesystem operations proceed.

Secure Upload Handling

File uploads implement additional hardening in the POST handler:

  • Directory validation: The upload directory passes isFilePathAllowed()
  • Symlink re-verification: Both directory and whitelist roots are resolved with realpathSync, then re-checked
  • Exclusive creation: Files write with fs.writeFileSync(..., { flag: "wx" }), preventing accidental overwrites unless replacement is explicitly requested

This prevents race conditions where a directory could be replaced with a symlink between validation and write.

Runtime Root Extension with allowFileRoot()

The security model permits dynamic expansion through allowFileRoot():

// app/api/default-cwd/route.ts
import { allowFileRoot } from "@/lib/file-access";

allowFileRoot(newCwdPath);  // instantly trusted for subsequent requests

This function normalizes the path, adds it to the in-memory additional roots set, and immediately patches the cached whitelist via globalThis.__piAllowedRootsCache, bypassing the 5-second TTL for instant availability.

Security Architecture Summary

Layer Defense Implementation Location
Whitelist construction Aggregates session dirs, auto-directories, and runtime additions lib/file-access.ts → getAllowedFileRoots()
Path normalization Slash-consistent, case-folded strings for reliable comparison lib/allowed-roots.ts → normalizeSlashes()
Lexical containment Zero-FS traversal prevention lib/path-security.ts → isPathWithinRoots()
Real-path containment Symlink-to-escape prevention lib/path-security.ts → isExistingPathWithinRoots()
API enforcement Mandatory 403 responses on policy violations app/api/*/route.ts files
Cache resilience globalThis storage survives Next.js hot-reload globalThis.__piAllowedRootsCache

Summary

  • Pi Web file access control relies on a runtime-constructed whitelist rather than static configuration, enabling dynamic multi-project workflows
  • Two-phase validation—lexical containment followed by real-path resolution—balances performance with symlink attack defense
  • Cross-platform path handling uses platform-specific resolvers and case-folding to ensure consistent behavior
  • Cache architecture on globalThis maintains performance across hot-reloads without sacrificing freshness
  • Universal API enforcement means no file operation bypasses the security model, with consistent HTTP 403 responses

Frequently Asked Questions

How does Pi Web prevent directory traversal attacks?

Pi Web uses lexical path containment in isPathWithinRoots() to verify that any requested path either equals or starts with an allowed root directory plus a separator. This check runs before filesystem access, rejecting patterns like ../../../etc/passwd that attempt to escape permitted directories.

Symlinks can point outside allowed directories even when placed inside them. The isExistingPathWithinRoots() function calls realpathSync on both the target and all whitelist roots, converting symbolic links to actual locations before running the containment check. This closes the "symlink escape" attack vector that purely lexical validation cannot detect.

Can the whitelist be modified at runtime?

Yes. The allowFileRoot() function in lib/file-access.ts permits dynamic addition of new roots. Additions take effect immediately by updating both the in-memory additional roots set and the globalThis.__piAllowedRootsCache, ensuring no delay before the new root becomes available for file operations.

What happens when a file access is denied?

All API routes return HTTP 403 with a JSON body containing { error: "Access denied" }. This consistent error response applies uniformly across file listing, reading, writing, upload, git operations, and worktree management endpoints.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →