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
cwdandprojectRoot - User-created folders – any directory matching
~/pi-cwd-*is automatically included - Runtime extensions – roots registered via the
allowFileRootAPI
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):
- Normalizes the supplied root
- Adds it to the in-memory "additional roots" Set
- 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) orisExistingFilePathAllowed(symlink-resolved) - A 5-second cache on
globalThis.__piAllowedRootsCacheoptimizes performance without sacrificing freshness - The
allowFileRootAPI enables dynamic, temporary access grants for workflows like worktree creation - All enforcement logic resides in
lib/file-access.ts,lib/allowed-roots.ts, andlib/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.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →