feat: file system access, behind a path policy and an approval #25
@@ -173,6 +173,30 @@
|
||||
{ "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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user