feat: file system access, behind a path policy and an approval #25

Merged
adrianbonpin merged 9 commits from feat/file-system-access into prod 2026-10-07 09:04:07 +00:00
Owner

Seven file tools, a path policy, and an approval step. Off by default.

The capability already existed. This extension reads files today (attachments.ts turns an attached screenshot into base64), Vicinae's own boilerplate extension imports node:fs/promises, and extensions run as unsandboxed Node worker threads: the runtime's only require patch is a lookup table for React and @vicinae/api. So this is not "can Lam touch files", it is "under what permission model". confirmAlert is implemented and rememberUserChoice is not, which is why the approval memory here is ours.

Where it may read and write. Reads: $HOME, minus a credential denylist (~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gh, ~/.netrc, ~/.docker/config.json, id_*, credentials.json, .env*, *.pem/*.key/*.p12), plus extra read roots from a preference. Writes: ~/Downloads, ~/Documents/Lam Agent, the extension support directory, plus extra write roots. Containment is checked after realpath, so a symlink is judged by where it lands, and it is a component comparison, so ~/Downloads-evil is not inside ~/Downloads.

Every change is a dialog naming the exact path. Allow is the default button. A refusal returns to the model as a refusal that says it is final. The ledger behind it is per answer: the second write to the same path while answering one question goes through, and the next question asks again. Delete and move skip the ledger entirely and ask every time, because an approval given for an edit is not consent to remove the thing that was just edited.

Bounded on purpose. Delete refuses a directory with anything in it and never recurses. A move refuses an existing destination. A binary file is refused with its size rather than returned as mojibake. Caps are stated in the output when they bite: 200 lines and 8 KB per read, 200 entries per listing, 50 results per search, 5 MB per file.

The disclosure is the point of the read boundary. Everything read is sent to the model provider, so a read is a disclosure even though it changes nothing on disk. That is why the denylist exists, why the switch is off by default (following ipGeolocation, the weaker precedent already in this repo), and why the prompt tells the model to read what the question needs and nothing more. Off means the tools are absent from toolsFor rather than refusing, so the model never learns they exist.

715 tests, 141 new. Four mutations each caught by the test that should catch them: checking containment before realpath, comparing path prefixes instead of components, keying the ledger on the written path, and letting a delete reuse a write approval.

Two things worth knowing for review. The alert shows the size and age of what it replaces but not its old line count, because counting lines means reading a file Lam is about to overwrite, and stat gives the rest for free. And the check-then-write is not atomic: a symlink swapped in between them by another process running as the same user would not be caught, which needs handle-based operations rather than a path policy. Both are written down in the README rather than left implied.

Seven file tools, a path policy, and an approval step. Off by default. **The capability already existed.** This extension reads files today (`attachments.ts` turns an attached screenshot into base64), Vicinae's own boilerplate extension imports `node:fs/promises`, and extensions run as unsandboxed Node worker threads: the runtime's only `require` patch is a lookup table for React and `@vicinae/api`. So this is not "can Lam touch files", it is "under what permission model". `confirmAlert` is implemented and `rememberUserChoice` is not, which is why the approval memory here is ours. **Where it may read and write.** Reads: `$HOME`, minus a credential denylist (`~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gh`, `~/.netrc`, `~/.docker/config.json`, `id_*`, `credentials.json`, `.env*`, `*.pem`/`*.key`/`*.p12`), plus extra read roots from a preference. Writes: `~/Downloads`, `~/Documents/Lam Agent`, the extension support directory, plus extra write roots. Containment is checked after `realpath`, so a symlink is judged by where it lands, and it is a component comparison, so `~/Downloads-evil` is not inside `~/Downloads`. **Every change is a dialog naming the exact path.** Allow is the default button. A refusal returns to the model as a refusal that says it is final. The ledger behind it is per answer: the second write to the same path while answering one question goes through, and the next question asks again. Delete and move skip the ledger entirely and ask every time, because an approval given for an edit is not consent to remove the thing that was just edited. **Bounded on purpose.** Delete refuses a directory with anything in it and never recurses. A move refuses an existing destination. A binary file is refused with its size rather than returned as mojibake. Caps are stated in the output when they bite: 200 lines and 8 KB per read, 200 entries per listing, 50 results per search, 5 MB per file. **The disclosure is the point of the read boundary.** Everything read is sent to the model provider, so a read is a disclosure even though it changes nothing on disk. That is why the denylist exists, why the switch is off by default (following `ipGeolocation`, the weaker precedent already in this repo), and why the prompt tells the model to read what the question needs and nothing more. Off means the tools are absent from `toolsFor` rather than refusing, so the model never learns they exist. 715 tests, 141 new. Four mutations each caught by the test that should catch them: checking containment before `realpath`, comparing path prefixes instead of components, keying the ledger on the written path, and letting a delete reuse a write approval. Two things worth knowing for review. The alert shows the size and age of what it replaces but not its old line count, because counting lines means reading a file Lam is about to overwrite, and `stat` gives the rest for free. And the check-then-write is not atomic: a symlink swapped in between them by another process running as the same user would not be caught, which needs handle-based operations rather than a path policy. Both are written down in the README rather than left implied.
adrianbonpin added 9 commits 2026-10-07 09:04:00 +00:00
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.
Ten tasks: the path policy and its operations, the approval dialog and its ledger,
seven tools, three preferences, what the model is told, the hook and setup screen
wiring, the README, and a verification task that mutation checks the policy rather
than trusting the suite.
One module with no Vicinae import, so the whole boundary tests headlessly: the
roots, the credential denylist, resolution that follows symlinks before judging
containment, and the seven operations, each returning a result rather than
throwing, so a refusal and an I/O failure reach the model in the same shape.

The mutating operations take an approval callback rather than importing a dialog,
which is what keeps this file free of UI and lets every refusal path be tested.

52 tests, including a symlink out of a root, a sibling whose name merely starts
like a root, and a temporary home with its own symlinks resolved first, because
mkdtemp on macOS hands back a path under /var.
The wording lives beside the dialog rather than beside the paths, which is what
keeps files.ts free of UI and lets every refusal path be tested without a
launcher. Vicinae's rememberUserChoice is declared in the API but not implemented
in the server, so the ledger is the only memory and it lasts one answer.

A dialog that cannot be shown is a refusal, not consent.
The file tools are absent from the list unless file access is on, which is the
difference between a model that cannot call them and one that is told not to. Each
handler is thin: argument checks here, and the operation itself in files.ts.

move_path reads `from`, not the shared path helper, which is the bug the tests
caught: an empty source path would have been refused by the policy on every move.
Three settings: the switch, and the two root fields. Off by default, because the
read half of this feature cannot ask before it happens, and `ipGeolocation` in this
repository is already off for the weaker version of the same reason.

The manifest keeps the file's own formatting rather than prettier's, since the
repository's format script has never covered package.json.
The runtime paragraph gains the tools, the roots and the approval rule when file
access is on, and keeps the old claim that Lam cannot read files when it is off,
because a model told the wrong thing about itself states it confidently.

The hook builds the policy and the ledger per turn, which is what makes the
approval last one answer rather than one session.
Replaces the promise that Lam does not touch your files, which stopped being true,
and says what replaced it: the roots, the approval dialog, the per-answer ledger,
the denylist, and the one disclosure the dialog cannot make on the user's behalf,
which is that every byte read leaves the machine in a model request.
The code never reads a file it is about to replace just to count its lines: stat
gives the size and the age, and the new content's line count is known without
reading anything. The spec overpromised the old count.
adrianbonpin merged commit 2adc6d74b5 into prod 2026-10-07 09:04:07 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: adrianbonpin/lam-agent-vicinae#25