Architecture
Overview
llm-chat-file extends the llm-chat framework with filesystem tools — file read/write, directory listing, entry search (by name, content, and timestamps), and workspace switching — all scoped to a configured workspace with access control.
Design
Workspace
The Workspace class is the central gatekeeper for all filesystem operations. It is constructed with a DirectoryConfiguration that defines which directories are readable and which are writable.
const ws = new Workspace({
accesses: [
{ type: AccessType.Read, path: '/var/log' },
{ type: AccessType.Write, path: '/home/project' },
],
});
Key methods:
normalize(input)— resolves relative paths againstcurrentPath; absolute paths are returned as-is bypath.resolvecanRead(absPath)— checks if a resolved path falls within any read or write access directorycanWrite(absPath)— checks if a resolved path falls within a write access directorygetAccesses()— returns all configured directory accesses with their types and resolved pathswalk(dir)— async generator that recursively walks directories, skipping common non-text directories (.git,node_modules, etc.) and silently dropping subdirectories that cannot be read due to permission errors
Path security
All tools follow the same access control pattern:
- The raw user-provided path is normalized via
ws.normalize(path) - The tool calls
ws.canRead()orws.canWrite()on the resolved path - Path traversal attempts (e.g.
../../etc/passwd) are blocked becausepath.resolve()resolves..segments before the access check
By default, symlinks are followed as-is: if a configured directory contains a symlink pointing outside, the symlink target is accessible. Set LLM_CHAT_FS_RESOLVE_SYMLINKS=true (or pass resolveSymlinks: true to DirectoryConfiguration) to resolve symlinks via fs.realpathSync.native() before access checks. This prevents symlink-based path traversal but may break legitimate symlinks.
Access rules:
| Operation | Check |
|---|---|
read_file |
canRead |
write_file |
canWrite |
search_entries |
canRead |
list_directory |
canRead |
create_folder |
canWrite |
delete_file |
canWrite |
move_file |
canWrite (both src and dest) |
file_access_info |
none |
entry_info |
canRead |
Tool classes
Each tool extends Tool from llm-chat:
- Constructor accepts a
Workspaceinstance (and optional configuration, e.g.,SearchConfigurationwithtimeoutMs) and callssuper(name, description, params) onExecute()validates parameters, normalizes paths, checks access, performs the operation, and returnsPartialToolResult- All errors are caught and returned as plain-string messages — tools never throw
Async directory walk
The Workspace.walk() method is an async generator that recursively walks a directory tree. It:
- Returns
{ filePath, dirent }entries for both files and directories - Skips directories whose names are listed in
DirectoryConfiguration.skipDirs - Silently drops subdirectories that cannot be read (permission errors) — partial results are better than failing the entire operation
- Is used by
SearchEntriesTooland bylist_directoryin recursive mode
Concurrency
switchWorkspace() uses async-mutex to prevent concurrent switches from interleaving. However, this mutex does not serialize against regular filesystem tool calls. See problems.md for details.
Read-before-write safety
The FilePool class enforces read-before-write safety: write/edit tools reject the operation if the file has never been read, or if it changed via mtime since the last tracked read.
FilePoolusesasync-mutex(Mutex) for thread safety, following the same pattern asswitchWorkspacerecordRead(resolved)storesDate.now()as the read timestampverifyWrite(resolved)callsfsp.stat(resolved)and comparesst.mtime.getTime() > lastRead— the filesystem mtime is the authoritative source for detecting external modificationsrecordWrite(resolved)storesDate.now()(uses clock time rather thanfstat— the tool just wrote the file, so avoiding the syscall is fine)- Edit tool internal reads (reading the file to apply a partial edit) do NOT count as a tracked read — only an explicit
read_filetool call satisfies the requirement WriteFileToolcallsverifyWrite(resolved, true)—allowNewskips the check for ENOENT so creating brand-new files works without a prior read- The feature is enabled by default; set
LLM_CHAT_FS_REQUIRE_READ_BEFORE_WRITE=falseor passrequireReadBeforeWrite: falsetoFileConfigurationto disable
Dependencies
@johannes.latzel/llm-chat— framework providingTool,ToolParameters,ToolParameterProperty, etc.async-mutex— concurrency protection for workspace switchingisbinaryfile— binary file detection