How Pi-Web Implements File Access Security Boundaries and Allow-Lists

Pi-Web enforces strict filesystem containment through a dynamic allow-list cached for 5 seconds, combining session working directories, project roots, temporary user CWDs, and runtime extensions, with path normalization and symlink-aware validation to prevent traversal attacks.

The agegr/pi-web repository protects its /api/files endpoints by maintaining an explicit file access security boundary rather than granting broad filesystem access. This security model dynamically aggregates allowed paths from multiple sources and validates every file request against a short-lived cache to balance performance with real-time updates.

The Allow-List Architecture

Pi-Web constructs its allow-list inside lib/file-access.ts by aggregating four distinct sources of trusted directories. When getAllowedFileRoots executes, it compiles these roots into a comprehensive containment set.

Session Working Directories and Project Roots

For every stored Pi session, the system automatically includes both the current working directory (cwd) and the top-level projectRoot. These are collected during cache construction:

roots.add(normalizeSlashes(s.cwd))
roots.add(normalizeSlashes(s.projectRoot))

According to the source code at lines 31-36 of file-access.ts, these paths represent the foundational security boundaries derived from active development sessions and their backing repositories.

Temporary User Directories

The scanner also detects directories matching the pattern ~/pi-cwd-YYYYMMDD created by the default-cwd endpoint. These are discovered by scanning the user’s $HOME directory:

roots.add(normalizeSlashes(path.join(homedir(), name)))

This logic appears at lines 40-44 of file-access.ts, ensuring that user-created temporary working directories are automatically incorporated into the security perimeter.

Runtime Extensions

Plugins and UI components can expand the allow-list dynamically via allowFileRoot. These additional roots are stored in a global Set managed by lib/allowed-roots.ts and merged during cache generation:

getAdditionalAllowedRoots().add(normalizedRoot)

As implemented at lines 19-23 of allowed-roots.ts, this mechanism provides runtime extensibility without requiring server restarts.

Cache Implementation and TTL

To avoid expensive session database scans on every request, Pi-Web caches the computed allow-list on globalThis.__piAllowedRootsCache with a 5-second TTL (5000 ms). The implementation at lines 9-13 and 24-28 of file-access.ts uses this global object specifically to survive Next.js hot-reloads during development.

Path Validation and Security Checks

When a request targets a specific file, Pi-Web validates access using isFilePathAllowed (lines 57-68 of file-access.ts). The function performs three critical security operations:

  1. Path Normalization – Resolves both target and root paths to absolute form using path.win32 for Windows paths or the default Node path module (lines 57-62)
  2. Case-Insensitive Comparison – Normalizes paths to lower-case when Windows is detected to handle case-insensitive filesystems correctly (lines 62-64)
  3. Containment Verification – Validates that the target is either exactly the root directory or starts with the root path plus a path separator, preventing traversal via ../ sequences (lines 64-68)

For operations that must follow symbolic links, Pi-Web delegates to isExistingPathWithinRoots (exposed from lib/path-security.ts). As referenced at lines 73-75 of file-access.ts, this routine resolves the real path using fs.realpath and then applies the same containment logic. This ensures that even if a symlink points outside the allowed roots, the resolved target is still validated against the security boundary.

Practical Implementation Examples

To verify a user-supplied path before processing:

import { getAllowedFileRoots, isFilePathAllowed } from '@/lib/file-access';

async function canReadPath(requestedPath: string): Promise<boolean> {
  const allowedRoots = await getAllowedFileRoots(); // Uses 5-second cache
  return isFilePathAllowed(requestedPath, allowedRoots);
}

For server-side endpoint validation:

export async function GET(req: Request) {
  const { path } = await req.json();
  const allowedRoots = await getAllowedFileRoots();

  if (!isFilePathAllowed(path, allowedRoots)) {
    return new Response('Forbidden', { status: 403 });
  }
  // Safe to proceed with file read
}

To dynamically expand access for a plugin:

import { allowFileRoot } from '@/lib/file-access';

// Expose a shared assets folder from a custom plugin
allowFileRoot('/srv/shared/assets');

Summary

  • Pi-Web uses an explicit allow-list derived from session data, project roots, temporary CWDs (~/pi-cwd-YYYYMMDD), and runtime extensions to restrict filesystem access
  • The allow-list is cached for 5 seconds on globalThis.__piAllowedRootsCache to optimize performance while allowing dynamic updates
  • Path traversal protection is enforced through normalization, case-insensitive comparison on Windows, and strict containment checks requiring the target to start with an allowed root plus a separator
  • Symbolic links are secured by resolving real paths before validation using isExistingPathWithinRoots in lib/path-security.ts
  • Runtime extensibility is supported via allowFileRoot in lib/allowed-roots.ts for plugins requiring additional directory access

Frequently Asked Questions

How does Pi-Web prevent path traversal attacks?

Pi-Web normalizes all paths to absolute form using the appropriate Node path module and validates that the requested file either exactly matches an allowed root or resides within it by verifying the path starts with the root plus a path separator. This containment logic, implemented in isFilePathAllowed, prevents ../ sequences from escaping permitted directories regardless of how the path is constructed.

Why is the allow-list cached for only 5 seconds?

The 5-second TTL (5000 ms) defined in file-access.ts balances performance against freshness. It avoids expensive database scans and directory listings on every API request while ensuring that new sessions, worktrees, or programmatically added roots become active within seconds without requiring manual cache invalidation.

Can plugins add their own allowed directories?

Yes. Plugins and UI components can call allowFileRoot('/path/to/dir') from lib/allowed-roots.ts to register additional roots at runtime. These extensions are stored in a global Set merged during getAllowedFileRoots execution, and changes reflect in the security boundary within the cache's 5-second TTL window.

How does the system handle case-insensitive filesystems on Windows?

When isFilePathAllowed detects a Windows path (using path.win32), it normalizes both the target and allowed roots to lower-case before comparison, as shown at lines 62-64 of file-access.ts. This ensures that paths like C:\Project\File.txt and c:\project\file.txt are treated identically for security validation, preventing bypasses through case variation.

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 →