From 36d7d2fdea4a61973e1d2334a65b049536bc7fe2 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 7 Oct 2026 17:00:52 +0800 Subject: [PATCH] feat: file access preferences 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. --- package.json | 26 ++++++++++++++++++++++- src/__tests__/preferences.test.ts | 34 +++++++++++++++++++++++++++++++ src/lib/preferences.ts | 27 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8ba5147..990cda7 100644 --- a/package.json +++ b/package.json @@ -173,7 +173,31 @@ { "title": "Unlimited (no cap)", "value": "unlimited" } ] } - ], + , + { + "name": "fileAccess", + "title": "File Access", + "label": "Let Lam read and change files", + "description": "Off by default. When this is on, Lam can list, search and read files under your home directory, and can create, change, move and delete files in Downloads, Documents/Lam Agent and its own folder. Every change is shown to you for approval first, credential stores are never readable, and anything Lam reads is sent to the model provider with the conversation.", + "type": "checkbox", + "required": false, + "default": false + }, + { + "name": "readRoots", + "title": "Extra Readable Folders", + "description": "Additional folders Lam may read, one absolute path per line. Your home directory is always readable, except for credential stores. Useful for a project folder or an external drive.", + "type": "textfield", + "required": false + }, + { + "name": "writeRoots", + "title": "Extra Writable Folders", + "description": "Additional folders Lam may change, one absolute path per line. Downloads and Documents/Lam Agent are always writable when File Access is on, and everything here is still approved change by change.", + "type": "textfield", + "required": false + } +], "dependencies": { "@vicinae/api": "^0.29.0", "zod": "^4.4.3" diff --git a/src/__tests__/preferences.test.ts b/src/__tests__/preferences.test.ts index 4d63cfc..1696a6f 100644 --- a/src/__tests__/preferences.test.ts +++ b/src/__tests__/preferences.test.ts @@ -296,3 +296,37 @@ describe("the model selection", () => { }); }); }); + +describe("file access preferences", () => { + it("is off when nothing says otherwise", () => { + // The default matters more than the setting here: an assistant that can read files + // should be one the user switched on, because reading is the half that cannot ask + // first. + expect(resolvePreferences({}).fileEnabled).toBe(false); + }); + + it("is on only when the checkbox is ticked", () => { + expect(resolvePreferences({ fileAccess: true }).fileEnabled).toBe(true); + expect(resolvePreferences({ fileAccess: false }).fileEnabled).toBe(false); + }); + + it("carries extra roots, and reports the ones it could not use", () => { + const resolved = resolvePreferences({ + readRoots: "/Volumes/Archive\nrelative/oops", + writeRoots: "/Volumes/Archive/scratch", + }); + + expect(resolved.extraReadRoots).toEqual(["/Volumes/Archive"]); + expect(resolved.rejectedReadRoots).toEqual(["relative/oops"]); + expect(resolved.extraWriteRoots).toEqual(["/Volumes/Archive/scratch"]); + expect(resolved.rejectedWriteRoots).toEqual([]); + }); + + it("has no extra roots when the fields are empty", () => { + const resolved = resolvePreferences({}); + expect(resolved.extraReadRoots).toEqual([]); + expect(resolved.extraWriteRoots).toEqual([]); + expect(resolved.rejectedReadRoots).toEqual([]); + expect(resolved.rejectedWriteRoots).toEqual([]); + }); +}); diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index 114b8b6..44a7f90 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -1,5 +1,6 @@ import { getPreferenceValues, LocalStorage } from "@vicinae/api"; import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants"; +import { splitRoots } from "./files"; /** * Preferences, read and normalised. @@ -24,6 +25,9 @@ export interface LamPreferences { resumeTimeout?: string; autoDeleteAfter?: string; maxToolRounds?: string; + fileAccess?: boolean; + readRoots?: string; + writeRoots?: string; } /** Where the model in use came from, so the UI can explain a divergence from the preference. */ @@ -62,6 +66,22 @@ export interface ResolvedPreferences { /** Whether to resolve a rough place from the IP address. Off unless asked for. */ ipGeolocation: boolean; webEnabled: boolean; + /** + * Whether the file tools are offered at all. + * + * Off by default, and off means the model never learns the tools exist rather than + * being refused when it calls one. The read side is the one that cannot show a + * dialog before it happens, so it is the side that decides this default; the write + * side raises a dialog every time and could reasonably have defaulted on. + */ + fileEnabled: boolean; + /** Extra read roots from the preference, already absolute. */ + extraReadRoots: string[]; + /** Extra write roots from the preference, already absolute. */ + extraWriteRoots: string[]; + /** Entries from the field that were not absolute paths, for the setup screen to report. */ + rejectedReadRoots: string[]; + rejectedWriteRoots: string[]; /** Undefined when reasoning is off, which is what the API client expects. */ think?: string; /** Whether the transcript lists the tools Lam called. */ @@ -202,6 +222,13 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences { ipGeolocation: raw.ipGeolocation ?? false, // Defaults to on: the preference is a checkbox the user has to actively untick. webEnabled: raw.webSearchEnabled ?? true, + // Off unless the user ticks the box: the read side of this feature sends file + // contents to the model, and nothing else in this extension reads the machine. + fileEnabled: raw.fileAccess ?? false, + extraReadRoots: splitRoots(raw.readRoots).roots, + extraWriteRoots: splitRoots(raw.writeRoots).roots, + rejectedReadRoots: splitRoots(raw.readRoots).rejected, + rejectedWriteRoots: splitRoots(raw.writeRoots).rejected, think: thinking && thinking !== "off" ? thinking : undefined, showToolCalls: raw.showToolCalls ?? true, showReasoning: raw.showReasoning ?? true,