How the Pi-Web File Access Allow-List Security Boundary Works

Pi‑Web restricts all file‑system operations to a dynamically-built whitelist of "allowed roots," with validation occurring before any fs call to prevent unauthorized access outside safe directory trees.

The file access allow-list security boundary is the core defense mechanism in Pi‑Web (agegr/pi-web). It ensures that every read, write, or listing operation stays confined to directories the application explicitly trusts. This article breaks down how the whitelist is constructed, cached, and enforced across the codebase.

How the Allow-List Is Built

The security model centers on getAllowedFileRoots() in lib/file-access.ts (lines 20–48). This function gathers safe locations from three sources:

  • Session directories – every stored session contributes its cwd and projectRoot
  • User-created folders – any directory matching ~/pi-cwd-* is automatically included
  • Runtime extensions – roots registered via the allowFileRoot API

The result is cached on globalThis.__piAllowedRootsCache with a 5-second TTL. This balances freshness (new working directories appear quickly) with performance (avoiding full directory scans on every request).

Path Normalization and Storage

Before storage, all roots pass through normalizeSlashes in lib/allowed-roots.ts (lines 15–18). This ensures:

  • Platform-agnostic matching – forward slashes on all platforms
  • Case-insensitivity – Set lookups work reliably across filesystems

The normalized roots are stored in a Set<string> for O(1) lookup performance.

Two-Tier Path Validation

Pi‑Web implements two distinct validation strategies depending on whether the target path exists:

Lexical Path Check (isFilePathAllowed)

For generic paths that may not exist yet, isFilePathAllowed(target, allowedRoots) (lines 51–54) performs a pure string comparison. It delegates to isPathWithinRoots in lib/path-security.ts without touching the filesystem.

This check is ideal for:

  • Validating user input before file creation
  • Preventing directory traversal attacks via ../ sequences

Resolved Path Check (isExistingFilePathAllowed)

For paths that must already exist, isExistingFilePathAllowed(target, allowedRoots) (lines 56–59) first resolves symlinks to their real location, then verifies the resolved path lies within an allowed root via isExistingPathWithinRoots.

This protects against symlink attacks where a malicious link points outside the whitelist.

Runtime Extension via allowFileRoot

Plugins and UI actions can extend the whitelist dynamically. The allowFileRoot(root) function in lib/allowed-roots.ts (lines 26–31):

  1. Normalizes the supplied root
  2. Adds it to the in-memory "additional roots" Set
  3. Injects it into the live cache if already populated

This enables temporary access grants, such as after a user creates a new worktree.

// Grant access to a newly created worktree
import { allowFileRoot } from '@/lib/file-access';

allowFileRoot('/repo/worktrees/feature-x');
// Immediately effective – no cache expiry wait

API Route Enforcement

All filesystem API routes import these helpers and validate before any fs operation. Typical usage patterns:

// Basic lexical check – for paths that may not exist
import { getAllowedFileRoots, isFilePathAllowed } from '@/lib/file-access';

const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(requestedPath, allowedRoots)) {
  return new Response('Access denied', { status: 403 });
}
// Resolved path check – for existing files (symlink-safe)
import {
  getAllowedFileRoots,
  isExistingFilePathAllowed,
} from '@/lib/file-access';

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

Routes using this pattern include:

  • /api/files/[...path]
  • /api/worktrees
  • /api/skills/*

Key Security Properties

Property Implementation Detail
Fail-closed design No filesystem call occurs without prior validation
Symlink resistance Resolved-path check defeats symlink-based escapes
Minimal cache window 5-second TTL limits exposure to stale data
Runtime extensibility allowFileRoot enables legitimate workflows without configuration files
Platform normalization Slash-normalization prevents case-sensitivity bypasses

Core Source Files

File Responsibility
lib/file-access.ts Builds allow-list, manages cache, exports validation helpers
lib/allowed-roots.ts Stores additional roots Set, implements allowFileRoot
lib/path-security.ts Low-level isPathWithinRoots and isExistingPathWithinRoots implementations
app/api/files/[...path]/route.ts Example API route with allow-list enforcement

Summary

  • The file access allow-list is a whitelist of directory roots built from session data, user folders, and runtime extensions
  • Validation happens before any filesystem access via isFilePathAllowed (lexical) or isExistingFilePathAllowed (symlink-resolved)
  • A 5-second cache on globalThis.__piAllowedRootsCache optimizes performance without sacrificing freshness
  • The allowFileRoot API enables dynamic, temporary access grants for workflows like worktree creation
  • All enforcement logic resides in lib/file-access.ts, lib/allowed-roots.ts, and lib/path-security.ts

Frequently Asked Questions

What happens if a requested path is outside all allowed roots?

The validation function returns false, and the API route returns a 403 Forbidden or 400 Bad Request response. No filesystem operation is attempted.

For existing files, isExistingFilePathAllowed calls isExistingPathWithinRoots, which resolves the symlink to its real target before performing the allow-list check. This prevents a symlink from escaping the whitelist.

Can the allow-list be modified after server startup?

Yes. The allowFileRoot(root) function adds roots to the in-memory Set and immediately injects them into the live cache. This enables plugins and UI actions to grant temporary access without restarting the server.

Why is the cache TTL only 5 seconds?

A 5-second time-to-live provides near-real-time visibility of new session working directories while avoiding the performance cost of rescanning the session directory on every single request.

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 →