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

> Learn how Pi-Web's file access allow-list security boundary prevents unauthorized access by validating fs calls against a whitelist of allowed roots before execution.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-18

---

**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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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.

```typescript
// 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:

```typescript
// 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 });
}

```

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts) | Builds allow-list, manages cache, exports validation helpers |
| [`lib/allowed-roots.ts`](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts) | Stores additional roots Set, implements `allowFileRoot` |
| [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts), [`lib/allowed-roots.ts`](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts), and [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/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.

### How does Pi-Web protect against symlink attacks?

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.