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

> Discover how Pi-Web's file access allow-list security model protects your system by dynamically building an allowed roots whitelist for safe file operations.

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

---

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

### Symlink Attack Protection

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

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

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

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

### How does Pi-Web protect against symbolic link attacks?

Pi-Web employs a two-tier validation strategy. For existing files, `isExistingFilePathAllowed()` (defined in [`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts)) resolves symbolic links using `isExistingPathWithinRoots` from [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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.