# How pi-web Manages File Access Security with the Allowed-Roots System

> Discover how pi-web secures file access with its allowed-roots system. Learn about dynamic whitelisting, lexical checks, and symlink attack prevention.

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

---

**pi-web enforces file access security through a dynamic allowed-roots system that maintains a cached whitelist of safe directories derived from session data and runtime extensions, validating every file operation against both lexical and real-path containment checks to prevent directory traversal and symlink attacks.**

The `agegr/pi-web` repository implements a strict read-only file-browser API that never accesses the file system without explicit authorization. By combining dynamic root discovery, a short-lived global cache, and Windows-aware path validation, the allowed-roots system ensures that list, preview, download, and upload operations remain confined to designated safe directories.

## Building the Dynamic Allowed-Roots Whitelist

The foundation of pi-web's security model lies in [`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts), where the `getAllowedFileRoots()` function assembles a comprehensive set of permitted directories. This function scans active sessions, project roots, temporary work directories, and any runtime additions to construct the whitelist.

```typescript
// lib/file-access.ts – getAllowedFileRoots()
export async function getAllowedFileRoots(): Promise<Set<string>> {
  const now = Date.now();
  const cached = globalThis.__piAllowedRootsCache;
  if (cached && cached.expiresAt > now) return cached.roots;   // ↪️ cached result

  const sessions = await listAllSessions();                    // ↪️ session CWDs
  const roots = new Set<string>();
  for (const s of sessions) {
    if (s.cwd)            roots.add(normalizeSlashes(s.cwd));
    if (s.projectRoot)    roots.add(normalizeSlashes(s.projectRoot));
  }

  // Add temporary “~/pi‑cwd‑YYYYMMDD” directories
  try {
    for (const name of readdirSync(homedir())) {
      if (/^pi-cwd-\d{8}$/.test(name)) {
        roots.add(normalizeSlashes(path.join(homedir(), name)));
      }
    }
  } catch {}  // ignore unreadable home

  // Merge any runtime‑added roots
  for (const root of getAdditionalAllowedRoots()) roots.add(root);

  globalThis.__piAllowedRootsCache = { roots, expiresAt: now + ALLOWED_ROOTS_TTL_MS };
  return roots;
}

```

*Source: [file-access.ts lines 20-48](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts#L20-L48)*

To prevent performance degradation from repeated session scans, the implementation stores results in `globalThis.__piAllowedRootsCache` with a **5-second TTL**. This cache ensures that high-frequency API requests reuse the computed root set without rescanning the session store or file system.

## Extending Allowed Roots at Runtime

Plugins and dynamic components can expand the whitelist without modifying core configuration through the `allowFileRoot()` function exported from [`lib/allowed-roots.ts`](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts). This helper maintains a separate global set (`__piAdditionalAllowedRoots`) and immediately updates the cache when invoked.

```typescript
// lib/allowed-roots.ts
export function allowFileRoot(root: string): void {
  if (!root) return;
  const normalizedRoot = normalizeSlashes(root);
  getAdditionalAllowedRoots().add(normalizedRoot);
  globalThis.__piAllowedRootsCache?.roots.add(normalizedRoot);
}

```

*Source: [allowed-roots.ts lines 26-31](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts#L26-L31)*

When integrating third-party extensions or temporary workspaces, call `allowFileRoot("/your/custom/path")` before file operations commence. The normalization ensures consistent path comparison regardless of trailing slashes or operating system differences.

## Path Containment Validation

The core security logic resides in [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts), which provides two distinct validation strategies. The **lexical check** (`isPathWithinRoots`) performs fast string comparison on normalized paths, while the **real-path check** (`isExistingPathWithinRoots`) resolves symbolic links to prevent symlink escape attacks.

### Lexical Containment

```typescript
// lib/path-security.ts – lexical check
export function isPathWithinRoots(target: string, roots: Set<string>): boolean {
  for (const root of roots) {
    const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
    const resolver = useWindowsRules ? path.win32 : path;
    const sep = useWindowsRules ? "\\" : path.sep;
    const normalized = resolver.resolve(target);
    const normalizedRoot = resolver.resolve(root);
    const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
    const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
    const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
    if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) return true;
  }
  return false;
}

```

*Source: [path-security.ts lines 10-22](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts#L10-L22)*

This implementation handles **Windows path semantics** automatically, converting to lowercase for comparison on Windows systems while respecting case sensitivity on Unix platforms. The function ensures that a path like `/allowed/subdir` passes validation against root `/allowed`, while `/allowed-malicious` fails.

### Real-Path Validation for Symlink Protection

When handling existing files or directories that might contain symbolic links, use `isExistingPathWithinRoots` (exposed as `isExistingFilePathAllowed` in the public API):

```typescript
export function isExistingPathWithinRoots(target: string, roots: Set<string>): boolean {
  let realTarget: string;
  try { realTarget = realpathSync(target); } catch { return false; }

  const realRoots = new Set<string>();
  for (const root of roots) {
    try { realRoots.add(realpathSync(root)); } catch {} // ignore stale roots
  }
  return isPathWithinRoots(realTarget, realRoots);
}

```

*Source: [path-security.ts lines 25-42](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts#L25-L42)*

By resolving both the target path and all allowed roots through `realpathSync`, this function prevents **symlink traversal attacks** where a malicious link inside an allowed directory points to sensitive system files outside the whitelist.

## Enforcing Security in API Routes

Every file-related endpoint in `app/api/files/[...path]/route.ts` imports the guard functions and implements a consistent validation pattern. Routes first retrieve the cached allowed roots, then validate paths before any `fs` operation.

### Upload Protection with Dual Validation

The upload handler demonstrates defense-in-depth by checking both the requested directory and its resolved real path:

```typescript
const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(directory, allowedRoots)) {
  return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// ...
const realDirectory = fs.realpathSync(directory);
if (!isFilePathAllowed(realDirectory, allowedRoots)) {
  return NextResponse.json({ error: "Access denied" }, { status: 403 });
}

```

*Source: [upload validation lines 88-98 & 103-115](https://github.com/agegr/pi-web/blob/main/app/api/files/%5B...path%5D/route.ts#L88-L115)*

This pattern ensures that even if an attacker creates a symlink in place of the upload directory between the initial check and the write operation, the resolved path validation blocks the request.

### Read and Download Guards

The GET handler combines root-based validation with session-specific references for temporary file access:

```typescript
const allowedRoots = await getAllowedFileRoots();
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
const allowedBySessionReference =
  !allowedByRoot && type !== "list" && await isFilePathReferencedBySession(filePath, sessionId);

if (!allowedByRoot && !allowedBySessionReference) {
  return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// ...
if (!isExistingFilePathAllowed(existingAuthorizationPath, allowedRoots)) {
  return NextResponse.json({ error: "Access denied" }, { status: 403 });
}

```

*Source: [GET handler lines 29-38 & 48-53](https://github.com/agegr/pi-web/blob/main/app/api/files/%5B...path%5D/route.ts#L29-L38%2C%20L48-L53)*

The same validation appears in [`app/api/worktrees/route.ts`](https://github.com/agegr/pi-web/blob/main/app/api/worktrees/route.ts), maintaining a consistent security boundary across the entire file API surface.

## Practical Implementation Examples

### Adding a Custom Root for Plugins

When exposing directories created by plugins, invoke the runtime extension helper:

```typescript
import { allowFileRoot } from "@/lib/file-access";

export function initPlugin() {
  // Expose plugin-specific data directory
  allowFileRoot("/var/pi-plugins/shared-data");
}

```

### Manual Path Validation

For custom file operations outside the standard API routes, explicitly check containment:

```typescript
import { getAllowedFileRoots, isFilePathAllowed } from "@/lib/file-access";
import { readFileSync } from "fs";

async function readConfigFile(filePath: string) {
  const roots = await getAllowedFileRoots();
  if (!isFilePathAllowed(filePath, roots)) {
    throw new Error("Access denied: path is outside allowed roots");
  }
  return readFileSync(filePath, "utf-8");
}

```

### Symlink-Safe File Operations

When writing files that user input could influence, always validate the resolved real path:

```typescript
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { realpathSync, writeFileSync } from "fs";

async function safeWriteFile(requestedPath: string, data: string) {
  const roots = await getAllowedFileRoots();
  
  // Resolve symlinks before final validation
  const realPath = realpathSync(requestedPath);
  if (!isExistingFilePathAllowed(realPath, roots)) {
    throw new Error("Access denied: symlink targets outside allowed roots");
  }
  
  writeFileSync(realPath, data);
}

```

## Summary

- **Dynamic whitelist construction**: The system aggregates allowed directories from session CWDs, project roots, temporary `~/pi-cwd-*` directories, and runtime extensions via `getAllowedFileRoots()` in [`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts).
- **Performance optimization**: A 5-second TTL cache stored in `globalThis.__piAllowedRootsCache` eliminates redundant session scans during high-frequency requests.
- **Dual validation strategy**: Lexical checks (`isFilePathAllowed`) provide fast validation for non-existent paths, while real-path checks (`isExistingFilePathAllowed`) resolve symlinks to prevent directory traversal.
- **Cross-platform compatibility**: Path normalization utilities in [`lib/paths.ts`](https://github.com/agegr/pi-web/blob/main/lib/paths.ts) handle Windows case-insensitivity and mixed separator formats automatically.
- **Consistent enforcement**: All API routes in `app/api/files/[...path]/route.ts` return **403 Access denied** immediately when `isFilePathAllowed` or `isExistingFilePathAllowed` returns false.

## Frequently Asked Questions

### How does pi-web prevent symlink attacks against the allowed-roots system?

The system uses `isExistingFilePathAllowed` (backed by `isExistingPathWithinRoots` in [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts)), which calls `realpathSync` on both the target path and all allowed roots before validation. This resolves symbolic links to their actual disk locations, ensuring that a symlink pointing outside the whitelist fails containment checks even if the link itself resides inside an allowed directory.

### What is the performance impact of validating every file request?

pi-web mitigates performance overhead through a **5-second TTL cache** (`ALLOWED_ROOTS_TTL_MS`) stored in `globalThis.__piAllowedRootsCache`. This cache persists the computed root set across requests, eliminating the need to rescan sessions or readdir the home directory on every API call. Lexical path validation operates purely on strings without disk I/O, adding minimal latency.

### Can I add allowed directories after the server starts?

Yes. The `allowFileRoot()` function in [`lib/allowed-roots.ts`](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts) dynamically extends the whitelist at runtime. This function updates both the global `__piAdditionalAllowedRoots` set and the active cache, making new roots immediately available without server restart. This capability supports plugin architectures that create directories on-demand.

### Does the allowed-roots system support Windows paths?

Yes. The containment logic in [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts) automatically detects Windows absolute paths and switches to `path.win32` for normalization. It handles case-insensitive comparison by converting paths to lowercase when Windows rules apply, and properly manages backslash separators to prevent bypasses involving mixed path formats.