How the File Access Allow-List Security Model Works in Pi-Web

Pi-Web restricts all file-system operations to a dynamically built whitelist of "allowed roots," combining session directories, user-created folders, and runtime extensions to ensure every file access stays within safe boundaries.

The file access allow-list security model in the agegr/pi-web repository enforces a strict boundary around filesystem operations. Every API route that reads, writes, or lists files must validate paths against this whitelist before touching the disk. The implementation centers on three core modules in the lib/ directory that work together to build, cache, and verify allowed paths.

How the Allow-List Is Constructed

The getAllowedFileRoots() function in lib/file-access.ts serves as the central authority for determining which directories are safe to access. It dynamically aggregates roots from multiple sources and caches the result to avoid redundant filesystem scans.

Dynamic Root Discovery from Active Sessions

The allow-list begins by collecting paths from every stored Pi session. For each session object s, the system extracts s.cwd (the current working directory) and s.projectRoot (the derived project root). These locations represent the legitimate workspace boundaries that the application knows are safe.

Automatic Inclusion of User-Created Directories

Beyond session-specific paths, Pi-Web automatically whitelists any directory matching the pattern ~/pi-cwd-*. This convention allows users to create ad-hoc working folders that immediately become accessible without explicit configuration.

Runtime Extensions via the API

Plugins and UI actions can extend the allow-list at runtime through the allowFileRoot(root) API exported from lib/allowed-roots.ts. When called, this function normalizes the supplied path using normalizeSlashes, adds it to an in-memory Set of additional roots, and injects it into the live cache if one exists. This enables temporary access grants, such as when a user creates a new worktree that must be immediately accessible.

Path Validation and Security Checks

All filesystem operations pass through two-tier validation functions that prevent access outside the declared roots. The checks are performed before any fs call, ensuring the server never exposes unauthorized files.

Lexical Path Validation (isFilePathAllowed)

For generic path strings that may not yet exist on disk, isFilePathAllowed(target, allowedRoots) performs a lexical check. It delegates to isPathWithinRoots (implemented in lib/path-security.ts) to verify that the string target lies under any allowed root without touching the filesystem. All roots are stored slash-normalized so that Set lookups remain case-insensitive and platform-agnostic.

Resolved Path Validation (isExistingFilePathAllowed)

When validating paths that correspond to existing files, isExistingFilePathAllowed(target, allowedRoots) first resolves symbolic links to their real locations. It then verifies the resolved path remains inside an allowed root via isExistingPathWithinRoots. This distinction protects against symlink attacks where a malicious link might point outside the whitelist.

The separation between lexical and resolved validation creates a robust defense against directory traversal. The lexical check (isFilePathAllowed) filters initial requests, while the resolved check (isExistingFilePathAllowed) closes the vulnerability gap for existing files that might be symbolic links.

Caching Strategy for Performance

To balance freshness with performance, getAllowedFileRoots() caches the computed allow-list on globalThis.__piAllowedRootsCache with a 5-second TTL. This short-lived cache prevents full session directory rescans on every API request while ensuring new working directories appear quickly. When the cache expires, the function rebuilds the list by rescanning active sessions and merging in any roots registered via allowFileRoot.

Implementing the Security Model in API Routes

API routes such as /api/files/[...path], /api/worktrees, and /api/skills/* import the validation helpers from lib/file-access.ts and enforce the security model before executing filesystem operations.

For paths that may not exist yet, routes use the lexical check:

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

export async function GET(req) {
  const url = new URL(req.url);
  const path = url.searchParams.get('path') ?? '';

  const allowedRoots = await getAllowedFileRoots();
  if (!isFilePathAllowed(path, allowedRoots)) {
    return new Response('Access denied', { status: 403 });
  }

  // Safe to proceed with file operations
}

When handling existing files that might contain symbolic links, routes use the resolved check:

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

const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(userPath, allowedRoots)) {
  throw new Error('Path outside allowed roots');
}

To grant access to a newly created worktree dynamically:

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

// After creating a worktree at /repo/worktrees/feature-x
allowFileRoot('/repo/worktrees/feature-x');

Summary

  • Dynamic root discovery: getAllowedFileRoots() in lib/file-access.ts aggregates safe paths from session cwd and projectRoot properties, plus user-created ~/pi-cwd-* directories.
  • Runtime extensibility: The allowFileRoot() function in lib/allowed-roots.ts enables temporary access grants without restarting the server.
  • Two-tier validation: isFilePathAllowed() performs fast lexical checks, while isExistingFilePathAllowed() resolves symlinks before validating, preventing directory traversal attacks.
  • Performance caching: A 5-second TTL cache on globalThis.__piAllowedRootsCache minimizes filesystem scans while maintaining fresh allow-lists.
  • Enforcement point: All API routes validate paths against the allow-list before executing any fs operations, ensuring the security boundary defined in lib/file-access.ts is never bypassed.

Frequently Asked Questions

What happens if a requested path is not in the allow-list?

The system returns an HTTP 403 Forbidden response (or 400 Bad Request depending on the route) before any filesystem operation occurs. Because validation happens in the API route handler using isFilePathAllowed() or isExistingFilePathAllowed(), the server never attempts to read, write, or list files outside the declared safe roots.

Pi-Web employs a two-tier validation strategy. For existing files, isExistingFilePathAllowed() (defined in lib/file-access.ts) resolves symbolic links using isExistingPathWithinRoots from lib/path-security.ts to verify the real location lies within an allowed root. This prevents attackers from using symlinks to access files outside the whitelist even if the symlink itself resides inside an allowed directory.

Can the allow-list be modified after the server starts?

Yes. The allowFileRoot(root) API in lib/allowed-roots.ts allows runtime extensions to the allow-list. When called, it immediately normalizes the path and adds it to both the in-memory Set of additional roots and the live cache (if present), making the new root effective for subsequent requests without requiring a server restart.

Where is the allow-list cache stored and how long does it persist?

The cache is stored on the global object globalThis.__piAllowedRootsCache as implemented in lib/file-access.ts. It persists for 5 seconds (TTL), after which getAllowedFileRoots() rebuilds the list by rescanning active sessions and merging runtime extensions. This short duration ensures new working directories appear quickly while minimizing filesystem overhead.

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 →