Seven file tools behind a path policy and an approval step, with the platform findings that shaped it: extensions are unsandboxed Node, confirmAlert exists, rememberUserChoice does not, and environment.supportPath is the one directory an extension is invited to write to. Records the decisions taken with the user (read, write, edit and destructive tools; one approval per path per answer; home minus a credential denylist; extra roots in preferences) and the assumptions that follow from them, including the two that go beyond the questions asked: delete and move asking every time, and file access being off until switched on.
28 KiB
File system access for Lam
Status: draft for review
Date: 2026-10-07
Problem
Lam is a chat window that cannot look at anything on the machine unless the user attaches a screenshot to a question. Every question about a file is therefore answered from memory of a description the user typed, and the README says so in as many words: "Chat only. Lam answers questions. It does not touch your files or run commands."
The capability is not the problem. src/lib/attachments.ts already imports
readFile from node:fs/promises to turn an attached screenshot into base64, and
src/lib/capture.ts writes capture files. The problem is that nothing here has a
permission model, and an assistant that can read and write without one is a
liability rather than a feature.
What the platform gives us, and what it does not
Established by reading the Vicinae source in /tmp/vicinae-src
(vicinaehq/vicinae, 7733e95):
- Extensions are unsandboxed Node. Each command runs in a worker thread of the
extension manager (
src/typescript/extension-manager/src/index.ts), and the onlyrequirepatch is a lookup table for React and@vicinae/api(src/typescript/extension-manager/src/patch-require.ts). Everything else falls through to the real loader, sonode:fsandnode:pathwork. Vicinae's own boilerplate extension uses them (extra/extension-boilerplate/src/wallpaper.tsximportsreaddirfromnode:fs/promises). - There is no OS-level permission to lean on, no manifest permission block
(
schemas/manifest.tshas none), andenvironment.canAccessonly coversFileSearch,BrowserExtension,Wallpaper,AIandWindowManagement. If Lam is to be bounded at all, this repository has to do the bounding. confirmAlertworks.src/server/src/extension/api/ui-service.hppimplements it with aCallbackAlertWidget, so an approval step can be shown from a running command.rememberUserChoicedoes not.Alert.Optionsdeclares it, and no C++ file mentions it, so "do not ask again" would have to be our own bookkeeping and must not claim to be the platform's.environment.supportPathis documented as the directory an extension may read and write, and is a sensible default write root.FileSearchexposes Vicinae's file index read only. It returns indexed filename matches, needs the index to be built, and yields no file contents, so it is not the shape the model needs. Not used.
The risk this feature creates
Everything Lam reads is sent to the model, which is Ollama Cloud unless the user self hosts. So reading a file is a disclosure even though it changes nothing on disk. That is why the read boundary in this spec is as considered as the write boundary, and why the README has to say it out loud.
Goals
- Lam can list a directory, find a file by name or content, and read a file.
- Lam can create, overwrite and edit files, and can delete and move them.
- Nothing outside the configured paths is readable or writable, and a symlink or a
..segment cannot change that. - Every operation that changes the disk is approved by the user, with the exact affected path on screen, and a refusal is a normal outcome rather than a failure.
- Credential stores stay unreadable by default, because their contents would end up in a model request.
- A refusal or a policy failure tells the model what happened and what it may do instead, so the turn continues and produces an answer.
Non-goals
- Running commands. There is no shell tool here, and adding one would make every other boundary in this spec decorative.
- Watching files, tailing logs, or any long lived handle.
- Recursive delete. See A4.
- Editing in place with a patch format. Replacement is exact string substitution.
- A directory creation tool. The only way a directory comes into existence here is
the parent chain
write_filecreates for the file it was asked to write, which is disclosed in the alert. - A file picker or a browse view. The tools take paths the model writes.
- Reading or writing outside
$HOME, except through a preference the user sets themselves. - Copying.
move_pathcovers the useful case and copying doubles the files a mistake can create.
Assumptions
The four decisions this cycle rested on were put to the user directly; the smaller ones that follow from them are recorded here.
- A1. An approval covers a path for one answer. One
runAgentcall is one answer, so the ledger lives for exactly that turn. The first write to a resolved path raises one alert; further writes to that same path in that answer go through without one; the next question starts empty. Chosen over "ask every time" because a model that needs three passes at one file would otherwise interrupt three times, and over "the roots are the permission" because a path mistake inside a write root would then destroy a file silently. - A2. Delete and move always ask, even for a path already approved for writing.
The one deliberate exception to A1, and the deviation from the user's answer that
was put back to them and confirmed. Every other operation leaves something
recoverable behind: an overwrite is a change the user watched, an edit is a
replacement, but a delete is the end of the file. An approval granted for an edit
ten seconds earlier is not consent to remove the thing that was just edited.
delete_pathandmove_paththerefore skip the ledger entirely. - A3. Reads are bounded by
$HOMEminus a credential denylist. Anything under the home directory is readable except the locations in the denylist below. Chosen over an unrestricted filesystem, which would put/etcand mounted volumes in a model request, and over an allowlist that starts empty, which would make the feature useless until it was configured. - A4.
delete_pathrefuses a non-empty directory and never recurses. A recursive delete is the one call where a misread path destroys an unbounded amount of work, and no confirmation dialog makes it recoverable. A single file, or an empty directory, is the whole scope. The refusal names what is in the directory so the model can ask for the contents one at a time. - A5.
move_pathrefuses to overwrite an existing destination. The alternative is a move that silently destroys the file it lands on. The model can delete the destination first, which asks again under A2. - A6.
write_fileoverwrites without a separate flag, and discloses which it is. The approval alert says whether the file exists, how big it is, and how many lines it has, so an overwrite is never a surprise. AmustNotExistflag would add a round trip and a second approval to the common case to guard against something the alert already shows. - A7. Relative paths resolve against
$HOME, not the working directory. A launcher command has no meaningful working directory, and~is expanded, because both are what the model will write by habit and a rejection would only cost a round trip. - A8.
search_filessearches names and optionally content. Content search is "which file mentions this string", which is the question that actually motivates searching at all. It reads files, so it discloses what it reads exactly asread_filedoes, and it is capped in depth and results. - A9. If the approval dialog cannot be shown, the call is refused. Fail closed. An unverified path in this repository is the confirmation rendering while a turn is streaming, which cannot be tested locally; refusing is the only safe default.
- A10. The ledger is keyed by resolved real path, not by the string the model
wrote.
~/Downloads/x.txt,/Users/me/Downloads/x.txtand a symlink to it are one approval, because they are one file. Keying on the written form would let a second spelling of the same path slip past an approval the user already gave for it, and would show the same file's change twice as two permissions. - A11. File access is off until it is switched on, and when it is off the tools do
not exist for the model. This is a third preference beyond the two the user asked
for, added because the read side is the silent one: a write always raises a dialog,
but a read goes straight into a model request, and the model may choose to read
without the user having asked about any file.
ipGeolocationin this repository is already off by default for the weaker version of the same reason (it discloses one thing about the user to a third party), so the defaults here follow that precedent rather than thewebSearchEnabledone. Off means absent fromtoolsFor, not merely refusing: the model never learns the tools exist, and the prompt paragraph is not added. One checkbox turns it on.
Design
Module layout
| file | job |
|---|---|
src/lib/files.ts (new) |
Path policy and every file operation. No Vicinae import, so the whole boundary unit tests headlessly. |
src/lib/approval.ts (new) |
The only Vicinae facing part: the confirmAlert wording, and the per-answer ledger. |
src/lib/tools.ts (edit) |
Seven definitions and thin handlers, plus a FILE_TOOL_NAMES set so toolsFor can leave them out entirely when the feature is off (A11). Approval arrives as an injected callback, the way ipPlace already does. |
src/lib/preferences.ts (edit) |
readRoots and writeRoots, parsed by a pure splitRoots(). |
src/lib/constants.ts (edit) |
The caps, and the prompt paragraph. |
src/lib/hooks/useChat.ts (edit) |
Creates the ledger per turn and injects the confirm callback. |
package.json (edit) |
The two new preferences in the manifest. |
README.md (edit) |
The promise that Lam does not touch files, replaced by what it now does. |
files.ts owns no UI and approval.ts owns no path logic, so the policy can be
tested without a dialog and the dialog without a disk.
Path model
read roots: $HOME minus the denylist
write roots: ~/Downloads
~/Documents/Lam Agent
environment.supportPath
+ whatever readRoots / writeRoots preferences add
Resolution, in order, for every operation that takes a path:
- Reject a non-string, an empty string, or a path containing a NUL byte.
- Expand a leading
~or~/; resolve a relative path against$HOME(A7). path.resolveto a normal form, so..segments are gone.realpaththe path. If it does not exist (the create case),realpaththe nearest existing ancestor and re-append the remainder.- Check containment against the roots after step 4, so a symlink pointing outside a root is judged by where it lands rather than by where it is written.
- For a read, check the denylist against the resolved path.
Containment is a component-wise comparison, not a string prefix:
/Users/me/Downloads-evil must not match the root /Users/me/Downloads.
The denylist, matched on the resolved path, either exactly or as a directory prefix:
| rule | why |
|---|---|
~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gh, ~/.docker/config.json, ~/.netrc |
Credential stores and key material |
basename .env or starting .env. |
Application secrets, the common shape |
basename id_rsa, id_dsa, id_ecdsa, id_ed25519, credentials.json |
Private keys and cloud credential files |
extension .pem, .key, .p12 |
Key material under a name no other rule catches |
The denylist is a guard, not a proof: a credential file with a name nobody thought of is still readable. That is stated in the risks rather than papered over.
Operations
Every function returns a result object rather than throwing, so a policy refusal and an I/O failure reach the model in the same shape.
type FileResult =
| { ok: true; content: string; path: string; bytes?: number }
| { ok: false; content: string; refused: "policy" | "approval" | "io" };
The distinction matters in one place only: a policy refusal means "there is nothing here you are allowed to touch", while an approval refusal means "the user said no to this one", and the model is told to stop trying in the first case and to ask rather than retry in the second.
| tool | parameters | notes |
|---|---|---|
list_directory |
path, hidden? |
Directories first, then files, each sorted. Capped at MAX_LIST_ENTRIES (200), with a count of what was left out. |
search_files |
path, pattern, contains? |
pattern is a glob (*, ?, **) matched against the basename; contains is a case-insensitive substring test on the file's first MAX_SEARCH_BYTES (64 KB). Capped at MAX_SEARCH_RESULTS (50) and MAX_SEARCH_DEPTH (8). A path that fails the read check is skipped, contents and all, so the denylist holds for search results as well as for direct reads. |
read_file |
path, offset?, limit? |
Numbered lines, stopping at whichever comes first of the default 200 lines and MAX_READ_CHARS (8000) characters, with the notice naming that cap so it is clear which one stopped it. Binary content (a NUL byte in the first 8 KB) is refused with its size and detected type rather than returned as mojibake. |
write_file |
path, content |
Creates the parent directory chain when it is missing, disclosed in the alert, since ~/Downloads/reports/may.md is a normal thing to ask for and there is no separate mkdir tool. Reports Created or Overwrote (was 1,240 bytes, 40 lines). |
edit_file |
path, old_string, new_string, all? |
Exact substitution. Refuses when old_string occurs zero times or more than once without all, naming the count so the model can add context instead of guessing. |
delete_path |
path |
One file, or an empty directory. A non-empty directory is refused with its entry count (A4). |
move_path |
from, to |
Both the source and the destination are policy checked and both are approved. Refuses an existing destination (A5). |
toolLabel gains "Reading a file", "Writing a file", "Editing a file", "Deleting a
file", "Moving a file", "Listing a folder", "Searching your files" for the activity
line.
Approval
approval.ts exports createApprovalLedger(confirm) and the wording.
type FileApproval = {
/** What is about to happen, in the user's terms. */
title: string;
/** The concrete path, tilde shortened, plus the size and shape of what changes. */
body: string;
/** Whether this one is irreversible, which decides the button style. */
destructive: boolean;
};
confirm is (approval: FileApproval) => Promise<boolean>, implemented in
useChat.ts with confirmAlert. The ledger remembers resolved paths approved
during the turn (A1, A10) and is consulted only by the five mutating operations
other than delete and move, which always ask (A2).
The alert content is the point of the whole design, so it is specified rather than left to the implementation:
Title: Overwrite report.md?
Body: /Users/me/Downloads/report.md
Replaces 1,240 bytes, 40 lines, last changed 3 days ago.
New content is 2,180 bytes, 61 lines.
[Allow] [Decline]
When the parent directory does not exist, the body gains a line before the rest:
Creates the folder ~/Downloads/reports as well. Nothing else appears in the
body that the user cannot act on, and the exact path is always there, because the
path is the thing being approved.
edit_fileshows the substitution itself, both sides truncated to 200 characters:old_string → new_string, which is the one alert where showing the change is better than showing the size of the file.delete_pathusesAlert.ActionStyle.Destructiveon the primary action and adds "This cannot be undone."move_pathshows both paths and says whether the destination exists.- A policy refusal never reaches the user as a dialog at all, because there is nothing to approve.
- The tool record keeps the refusal text, so a transcript with Show Tool Calls on
shows what was declined next to what was allowed. No new field is needed for that,
which is why
ToolCallRecorddoes not change in this spec.
Refusals the model reads
Short, factual, and actionable, in the same voice as the rest of the tool output:
Refused: /etc/passwd is outside the paths Lam may read. Readable: your home
directory, except credential stores. Nothing was read.
Refused: ~/.ssh/id_ed25519 is a credential store, so Lam does not read it.
Refused: the user declined to write /Users/me/Downloads/draft.md. Nothing was
written. Ask before trying again.
Refused: ~/Downloads/archive is a directory with 42 entries, and Lam does not
delete directories recursively. List it and delete what you actually need.
Prompt
The system prompt gains a paragraph naming the write roots, saying that every change is approved by the user and that a refusal is final, that paths outside the roots are refused outright, and that a large file should be read in parts. It also gains one line of disclosure: file contents go to the model provider, so Lam reads what the question needs and nothing more.
Preferences
Two new text fields in package.json, mirrored in LamPreferences, plus the switch
from A11:
| preference | shape |
|---|---|
fileAccess |
Checkbox. Off by default, and off means the seven tools are absent from toolsFor and the prompt paragraph is not added. |
readRoots |
Extra read roots, one absolute path per line |
writeRoots |
Extra write roots, one absolute path per line |
splitRoots(value) in files.ts is pure and tested: it splits on newlines and
commas, trims, expands ~, drops empty entries, keeps a path that does not exist
yet (a write root may be created later), and reports entries that are not absolute
rather than silently ignoring them. A bad entry does not disable the others, and
the defaults above always apply, so an empty preference is the same as no
preference.
Error handling
| Situation | Behaviour |
|---|---|
| Path outside every root | Refused, naming the roots. The user is not prompted. |
| Path inside the denylist | Refused, naming the rule. |
Symlink or .. escaping a root |
Resolved first, then refused, and the refusal names the real location. |
| Path does not exist (read) | ENOENT reported with the path, not a stack. |
| Path exists (create) | Reported as an overwrite with the current size. |
| Permission denied by the OS | Refused with the OS message, which is already specific. |
| File larger than the cap | The cap is applied and stated; nothing is silently cut. |
| Binary file | Refused with size and detected type. |
| Non-empty directory delete | Refused with the entry count (A4). |
| Move onto an existing path | Refused, naming both paths (A5). |
| User declines | Refusal returned as a tool result with isError: true, turn continues. |
| Approval dialog cannot be shown | Refused (A9). |
| Ledger already holds the path | No dialog for a write or an edit; a delete or a move asks anyway (A2). |
| Feature off (A11) | The tools are not offered, so the model cannot call them |
Testing
files.test.ts, new, the bulk of the suite: a table of resolution cases (~, relative,.., symlink out of a root,/Users/me/Downloads-evil, denylist hits and near misses, non-existent parent chains), plus operation tests against a real temporary directory built withmkdtemp, including the delete and move refusals, the binary guard, line offsets, and the caps.approval.test.ts, new: the ledger approves once per resolved path per turn, treats two spellings of one path as one entry (A10), and never suppresses a delete or a move (A2); the wording for each of the five operations.tools.test.ts: each tool dispatches to the right operation; a declined call returnsisErrorwith the refusal text; a policy refusal never calls the confirm callback; the tools stay out of the list when the feature is off.preferences.test.ts:splitRootsfor empty, comma-separated, newline-separated, padded,~, relative and repeated input.- Mutation checks on the policy table: inverting a containment comparison, checking
before
realpath, and keying the ledger on the written path must each fail a test.
Approaches considered
- One implementation in
tools.ts, importingconfirmAlertdirectly. Fewer files, but it makes the tool layer Vicinae dependent, against the deliberate split that letstools.tsandagent.tsrun in a plain unit test, and it puts the policy somewhere a test cannot reach it without the runtime. - A separate process holding the file access, with the extension talking to it. A real boundary, and the only one an attacker could not walk around, but it needs the toolchain to spawn and supervise a process, and it moves the approval dialog somewhere it cannot be shown.
- Vicinae's
FileSearchfor discovery. Rejected: indexed names only, no contents, and it needs the user's index to be enabled and current. - An allowlist that starts empty. Rejected in A3: the feature would do nothing until configured, and the first experience of a new capability should not be a refusal.
- Asking on every mutating call (the "every write asks" option). Rejected in A1 in favour of one approval per path per answer.
- Recursive delete with a stronger confirmation. Rejected in A4.
Risks
- Reading is disclosure. Every byte read leaves the machine in a model request. The denylist and the home boundary limit the blast radius; they cannot make a file safe to read. The README will say this plainly, and the prompt will tell the model to read only what the question needs.
- The denylist is a list of guesses. A key file with an unusual name under an allowed path is readable. This is a denylist by construction, so it is bounded by what was thought of.
- The confirmation has not been seen rendering. Vicinae cannot be run in this
environment, so
confirmAlertduring a streaming turn is reasoned from the C++ implementation. A9 fails closed, and the first real use will tell us. rememberUserChoiceis not implemented, so nothing here may offer "do not ask again" as though the platform provided it. The ledger is per answer, which is a defensible unit and not a promise about the future.- A model with write access can still do damage inside a write root. Once
~/Downloadsis approved, a wrong path inside it is real damage. That is why the alert shows the concrete path, size and line count rather than the tool name. - A long turn can spend many approvals. The ledger limits this to one per distinct path, and the tool round cap bounds it further.
- The check and the write are not atomic. Between resolving a path and acting on
it, a symlink could be swapped so that the write lands somewhere the policy did not
clear. Defending that needs
O_NOFOLLOWand an open file handle per operation, which is a different design, and the threat it guards against is another process already running as the user. Out of scope, stated rather than implied.