2684 lines
96 KiB
Markdown
2684 lines
96 KiB
Markdown
# File System Access Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Give Lam seven file tools (list, search, read, write, edit, delete, move) that work only inside configured read and write roots, and only after the user approves every change in a dialog.
|
|
|
|
**Architecture:** `src/lib/files.ts` holds the whole path policy and every operation, with no Vicinae import, so the boundary unit tests headlessly. It takes an approval callback rather than importing a dialog, and `src/lib/approval.ts` is the only module that imports `confirmAlert`, where the wording and the per-answer ledger live. `src/lib/tools.ts` gains seven thin handlers, and `useChat.ts` builds the policy once per session and the ledger once per turn. The spec is `docs/superpowers/specs/2026-10-07-file-system-access-design.md`.
|
|
|
|
**Tech Stack:** TypeScript, React (Vicinae extension API), `node:fs/promises`, vitest, zod, prettier, eslint. Commands: `npx tsc --noEmit`, `npx eslint .`, `npx vitest run`, `npx prettier --check <files>`.
|
|
|
|
**Verified context:**
|
|
|
|
- Extensions are unsandboxed Node worker threads: `patch-require.ts` in the Vicinae runtime only intercepts React and `@vicinae/api`, so `node:fs/promises` works, and `src/lib/attachments.ts` already imports `readFile` from it.
|
|
- `confirmAlert(options: Alert.Options): Promise<boolean>` is implemented (`src/server/src/extension/api/ui-service.hpp`). `Alert.Options` has `title`, `message`, `primaryAction`, `dismissAction`, `icon`. Its `rememberUserChoice` flag is declared but not implemented in C++, so nothing may offer "do not ask again".
|
|
- `environment.supportPath` is the documented directory an extension may read and write.
|
|
- `executeTool(name, args, context)` in `src/lib/tools.ts:172` returns `ToolResult { content, isError, sources? }` and never throws.
|
|
- `runAgent` builds the `ToolContext` for each call from an explicit field list (`src/lib/agent.ts:236-243`), so a new field must be added there by hand.
|
|
- `toolsFor({ webEnabled })` at `src/lib/tools.ts:144` filters by name set; `toolLabel` at `src/lib/tools.ts:139` feeds the activity line.
|
|
- `composeSystemPrompt(customPrompt, context)` at `src/lib/history.ts:111` appends `RECENCY_HINT` when `context.webTools`, then `describeRuntime(context)`, which currently states at line 78 that Lam cannot read files.
|
|
- `resolvePreferences(raw)` in `src/lib/preferences.ts` is pure and fully tested; `src/setup.tsx` renders the resolved values.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Responsibility |
|
|
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| `src/lib/files.ts` | Create: path policy (`createPolicy`, `resolvePath`, `isWithin`, `denialFor`, `readDenial`, `writeDenial`, `splitRoots`) and the seven operations, returning `FileResult` |
|
|
| `src/lib/approval.ts` | Create: `describeApproval` wording, `createApprovalLedger`, `confirmWithAlert` |
|
|
| `src/lib/constants.ts` | Modify: the seven caps and `FILE_TOOL_GUIDANCE` |
|
|
| `src/lib/tools.ts` | Modify: `ToolContext.files`, seven definitions, seven handlers, labels, `FILE_TOOL_NAMES`, `toolsFor` gating |
|
|
| `src/lib/agent.ts` | Modify: pass `files` through to `executeTool` |
|
|
| `src/lib/preferences.ts` | Modify: `fileAccess`, `readRoots`, `writeRoots` |
|
|
| `src/lib/history.ts` | Modify: `RuntimeContext.fileTools` and `fileRoots`, and the runtime paragraph |
|
|
| `src/lib/hooks/useChat.ts` | Modify: build the policy per session, the ledger per turn, gate the tools |
|
|
| `src/setup.tsx` | Modify: show whether file access is on, and where it may write |
|
|
| `package.json` | Modify: the three preferences |
|
|
| `README.md` | Modify: the promise that Lam does not touch files, and what replaced it |
|
|
| `src/__tests__/files.test.ts` | Create: policy table and operations against a temporary directory |
|
|
| `src/__tests__/approval.test.ts` | Create: ledger and wording |
|
|
| `src/__tests__/tools.test.ts` | Modify: dispatch, refusals, gating |
|
|
| `src/__tests__/preferences.test.ts` | Modify: the three new preferences |
|
|
| `src/__tests__/history.test.ts` | Modify: the runtime paragraph on and off |
|
|
|
|
---
|
|
|
|
### Task 1: The caps and the path policy
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/constants.ts` (append at the end)
|
|
- Create: `src/lib/files.ts`
|
|
- Create: `src/__tests__/files.test.ts`
|
|
|
|
- [ ] **Step 1: Add the caps**
|
|
|
|
Append to `src/lib/constants.ts`:
|
|
|
|
```ts
|
|
/**
|
|
* File access caps.
|
|
*
|
|
* Every one of these exists because the alternative is a tool that can swallow a
|
|
* turn's whole context window or a worker's heap: a directory with 40,000 entries,
|
|
* a log file of 900 MB, a search that walks a home directory to its leaves. Each
|
|
* cap is stated in the tool's own output when it bites, so the model knows it is
|
|
* looking at part of something rather than all of it.
|
|
*/
|
|
export const MAX_READ_LINES = 200;
|
|
export const MAX_READ_CHARS = 8000;
|
|
export const MAX_LIST_ENTRIES = 200;
|
|
export const MAX_SEARCH_RESULTS = 50;
|
|
export const MAX_SEARCH_DEPTH = 8;
|
|
export const MAX_SEARCH_BYTES = 64 * 1024;
|
|
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
|
|
/**
|
|
* The file paragraph added to the runtime description when the tools are on.
|
|
*
|
|
* Separate from `describeRuntime` because it is the part that does not change with
|
|
* the configured roots: what a refusal means, and the one thing about file reading
|
|
* the user cannot see from the dialog, which is that the contents leave the machine.
|
|
*/
|
|
export const FILE_TOOL_GUIDANCE =
|
|
"A refused change is final: say what you could not do rather than trying again. Anything you read is sent to the model provider with the conversation, so read what the question needs and nothing more, and read a large file in parts with offset and limit instead of asking for the whole thing.";
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing policy tests**
|
|
|
|
Create `src/__tests__/files.test.ts`:
|
|
|
|
```ts
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
createPolicy,
|
|
denialFor,
|
|
isWithin,
|
|
readDenial,
|
|
resolvePath,
|
|
splitRoots,
|
|
writeDenial,
|
|
type PathPolicy,
|
|
} from "../lib/files";
|
|
|
|
describe("isWithin", () => {
|
|
it("accepts the root itself and a path under it", () => {
|
|
expect(isWithin("/Users/me/Downloads", "/Users/me/Downloads")).toBe(true);
|
|
expect(isWithin("/Users/me/Downloads/a/b.md", "/Users/me/Downloads")).toBe(true);
|
|
});
|
|
|
|
it("rejects a sibling whose name merely starts the same way", () => {
|
|
// The whole reason containment is component-wise: a string prefix would let
|
|
// this through, and it is not inside the root.
|
|
expect(isWithin("/Users/me/Downloads-evil/x", "/Users/me/Downloads")).toBe(false);
|
|
expect(isWithin("/Users/me", "/Users/me/Downloads")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("splitRoots", () => {
|
|
const home = "/Users/me";
|
|
|
|
it("splits on newlines and commas, trimming as it goes", () => {
|
|
expect(splitRoots("/a\n/b, /c", home).roots).toEqual(["/a", "/b", "/c"]);
|
|
});
|
|
|
|
it("is empty for empty or missing input", () => {
|
|
expect(splitRoots(undefined, home).roots).toEqual([]);
|
|
expect(splitRoots(" \n , ", home).roots).toEqual([]);
|
|
});
|
|
|
|
it("expands a home shorthand", () => {
|
|
expect(splitRoots("~/Code\n~", home).roots).toEqual(["/Users/me/Code", "/Users/me"]);
|
|
});
|
|
|
|
it("rejects a relative path instead of guessing what it means", () => {
|
|
const result = splitRoots("/a\nrelative/path\n/b", home);
|
|
expect(result.roots).toEqual(["/a", "/b"]);
|
|
expect(result.rejected).toEqual(["relative/path"]);
|
|
});
|
|
|
|
it("keeps one copy of a repeated root", () => {
|
|
expect(splitRoots("/a\n/a", home).roots).toEqual(["/a"]);
|
|
});
|
|
});
|
|
|
|
describe("resolvePath", () => {
|
|
let home: string;
|
|
|
|
beforeAll(async () => {
|
|
home = await mkdtemp(path.join(tmpdir(), "lam-files-"));
|
|
await mkdir(path.join(home, "Downloads", "reports"), { recursive: true });
|
|
await writeFile(path.join(home, "Downloads", "report.md"), "hello\n");
|
|
// A symlink that points out of the write roots, which the policy must judge by
|
|
// where it lands rather than by where it is written.
|
|
await symlink(path.join(home, "outside"), path.join(home, "Downloads", "escape"));
|
|
await mkdir(path.join(home, "outside"), { recursive: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(home, { recursive: true, force: true });
|
|
});
|
|
|
|
const policy = (): PathPolicy => createPolicy({ home, supportPath: path.join(home, "support") });
|
|
|
|
it("returns the real path of a file that exists", async () => {
|
|
const result = await resolvePath(path.join(home, "Downloads", "report.md"), policy());
|
|
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
|
});
|
|
|
|
it("resolves a relative path against home", async () => {
|
|
const result = await resolvePath("Downloads/report.md", policy());
|
|
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
|
});
|
|
|
|
it("expands a leading tilde", async () => {
|
|
const result = await resolvePath("~/Downloads/report.md", policy());
|
|
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
|
});
|
|
|
|
it("names the nearest existing ancestor for a path that does not exist yet", async () => {
|
|
const result = await resolvePath(path.join(home, "Downloads", "new", "file.md"), policy());
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
path: path.join(home, "Downloads", "new", "file.md"),
|
|
missing: true,
|
|
anchor: path.join(home, "Downloads"),
|
|
});
|
|
});
|
|
|
|
it("resolves .. before deciding, so it cannot walk out of a root", async () => {
|
|
const result = await resolvePath(path.join(home, "Downloads", "..", "..", "etc"), policy());
|
|
expect(result).toMatchObject({ ok: true, path: path.resolve(home, "..", "etc") });
|
|
});
|
|
|
|
it("follows a symlink to where it lands", async () => {
|
|
const result = await resolvePath(path.join(home, "Downloads", "escape", "x.md"), policy());
|
|
expect(result).toMatchObject({ ok: true, path: path.join(home, "outside", "x.md") });
|
|
});
|
|
|
|
it("refuses an empty path or one with a null byte", async () => {
|
|
expect((await resolvePath("", policy())).ok).toBe(false);
|
|
expect((await resolvePath("a\0b", policy())).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("readable and writable", () => {
|
|
const home = "/Users/me";
|
|
const policy = createPolicy({ home, supportPath: "/Users/me/support" });
|
|
|
|
it("reads anything under home", () => {
|
|
expect(readDenial(path.join(home, "Projects", "a.ts"), policy)).toBeUndefined();
|
|
});
|
|
|
|
it("refuses anything outside home", () => {
|
|
expect(readDenial("/etc/passwd", policy)).toContain("outside the paths");
|
|
});
|
|
|
|
it("refuses each credential store by name", () => {
|
|
for (const denied of [
|
|
path.join(home, ".ssh", "id_rsa"),
|
|
path.join(home, ".gnupg", "secring.gpg"),
|
|
path.join(home, ".aws", "credentials"),
|
|
path.join(home, ".config", "gh", "hosts.yml"),
|
|
path.join(home, ".netrc"),
|
|
path.join(home, ".docker", "config.json"),
|
|
path.join(home, "Projects", ".env"),
|
|
path.join(home, "Projects", ".env.local"),
|
|
path.join(home, "keys", "server.pem"),
|
|
path.join(home, "keys", "server.key"),
|
|
path.join(home, "keys", "cert.p12"),
|
|
path.join(home, "creds", "credentials.json"),
|
|
path.join(home, "keys", "id_ed25519"),
|
|
]) {
|
|
expect(denialFor(denied, policy), denied).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("leaves a near miss alone", () => {
|
|
// Names that merely resemble the rules: a denylist that over-matches teaches
|
|
// the user that the feature is broken rather than careful.
|
|
for (const allowed of [
|
|
path.join(home, "Projects", "environment.md"),
|
|
path.join(home, "Projects", ".envrc.example"),
|
|
path.join(home, "Documents", "keys.keynote"),
|
|
path.join(home, "Documents", "id_ed25519.pub"),
|
|
path.join(home, "Downloads", "pem"),
|
|
]) {
|
|
expect(denialFor(allowed, policy), allowed).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("writes only inside the write roots", () => {
|
|
expect(writeDenial(path.join(home, "Downloads", "a.md"), policy)).toBeUndefined();
|
|
expect(writeDenial(path.join(home, "Documents", "Lam Agent", "a.md"), policy)).toBeUndefined();
|
|
expect(writeDenial("/Users/me/support/a.md", policy)).toBeUndefined();
|
|
expect(writeDenial(path.join(home, "Documents", "taxes.md"), policy)).toContain("outside the paths");
|
|
});
|
|
|
|
it("adds the roots the preferences name", () => {
|
|
const extended = createPolicy({
|
|
home,
|
|
readRoots: ["/Volumes/Archive"],
|
|
writeRoots: ["/Volumes/Archive/scratch"],
|
|
});
|
|
|
|
expect(readDenial("/Volumes/Archive/db.sqlite", extended)).toBeUndefined();
|
|
expect(writeDenial("/Volumes/Archive/scratch/a.md", extended)).toBeUndefined();
|
|
expect(writeDenial("/Volumes/Archive/db.sqlite", extended)).toContain("outside the paths");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/files.test.ts`
|
|
Expected: FAIL, `Failed to resolve import "../lib/files"`.
|
|
|
|
- [ ] **Step 4: Write the policy**
|
|
|
|
Create `src/lib/files.ts`:
|
|
|
|
```ts
|
|
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
MAX_LIST_ENTRIES,
|
|
MAX_READ_CHARS,
|
|
MAX_READ_LINES,
|
|
MAX_SEARCH_BYTES,
|
|
MAX_SEARCH_DEPTH,
|
|
MAX_SEARCH_RESULTS,
|
|
MAX_FILE_BYTES,
|
|
} from "./constants";
|
|
|
|
/**
|
|
* Where Lam may read and write, and how a written path is turned into a real one.
|
|
*
|
|
* No Vicinae import anywhere in this file, and no dialog: the policy is a value
|
|
* that can be built in a test, and approval arrives as a callback from the caller.
|
|
* That split is what makes the whole boundary testable without a running launcher.
|
|
*/
|
|
|
|
export interface PolicyOptions {
|
|
/** The home directory. Overridden in tests, which is why it is not read inline. */
|
|
home?: string;
|
|
/** `environment.supportPath`, the one directory an extension is invited to write to. */
|
|
supportPath?: string;
|
|
/** Extra read roots from the preference. */
|
|
readRoots?: string[];
|
|
/** Extra write roots from the preference. */
|
|
writeRoots?: string[];
|
|
}
|
|
|
|
export interface PathPolicy {
|
|
home: string;
|
|
readRoots: string[];
|
|
writeRoots: string[];
|
|
}
|
|
|
|
/** Directories under home that hold credentials, matched on the resolved path. */
|
|
const DENIED_DIRECTORIES = [".ssh", ".gnupg", ".aws", ".config/gh"];
|
|
/** Files under home that hold credentials. */
|
|
const DENIED_FILES = [".netrc", ".docker/config.json"];
|
|
/** Basenames that are key material or credentials wherever they sit. */
|
|
const DENIED_BASENAMES = ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "credentials.json"];
|
|
/** Extensions that mean key material. */
|
|
const DENIED_EXTENSIONS = [".pem", ".key", ".p12"];
|
|
/** A basename that is an environment file, exactly or with a suffix. */
|
|
const DENIED_PREFIXES = [".env"];
|
|
|
|
/** Directories a search does not walk into, because they are noise and they are large. */
|
|
const SKIP_DIRECTORIES = [".git", "node_modules"];
|
|
|
|
function unique(values: string[]): string[] {
|
|
return [...new Set(values.filter((value) => value.length > 0))];
|
|
}
|
|
|
|
export function createPolicy(options: PolicyOptions = {}): PathPolicy {
|
|
const home = options.home ?? homedir();
|
|
const under = (...parts: string[]) => path.join(home, ...parts);
|
|
|
|
return {
|
|
home,
|
|
readRoots: unique([home, ...(options.readRoots ?? [])]),
|
|
writeRoots: unique([
|
|
under("Downloads"),
|
|
under("Documents", "Lam Agent"),
|
|
...(options.supportPath ? [options.supportPath] : []),
|
|
...(options.writeRoots ?? []),
|
|
]),
|
|
};
|
|
}
|
|
|
|
export interface RootList {
|
|
roots: string[];
|
|
/** Entries that are not absolute paths, reported rather than silently dropped. */
|
|
rejected: string[];
|
|
}
|
|
|
|
/**
|
|
* Parses a roots preference: one path per line or comma separated.
|
|
*
|
|
* A rejected entry does not disable the others, and a root that does not exist yet
|
|
* is kept, because a write root may be created after it is configured.
|
|
*/
|
|
export function splitRoots(value: string | undefined, home: string = homedir()): RootList {
|
|
const roots: string[] = [];
|
|
const rejected: string[] = [];
|
|
|
|
for (const piece of (value ?? "").split(/[\n,]/)) {
|
|
const trimmed = piece.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const expanded = expandHome(trimmed, home);
|
|
if (!path.isAbsolute(expanded)) {
|
|
rejected.push(trimmed);
|
|
continue;
|
|
}
|
|
roots.push(expanded);
|
|
}
|
|
|
|
return { roots: unique(roots), rejected: unique(rejected) };
|
|
}
|
|
|
|
/** `~` and `~/x` become home. Anything else is left alone. */
|
|
function expandHome(written: string, home: string): string {
|
|
if (written === "~") return home;
|
|
if (written.startsWith("~/")) return path.join(home, written.slice(2));
|
|
return written;
|
|
}
|
|
|
|
/** True when `target` is `root` itself or sits under it, compared component by component. */
|
|
export function isWithin(target: string, root: string): boolean {
|
|
if (target === root) return true;
|
|
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
return target.startsWith(prefix);
|
|
}
|
|
|
|
export interface PathResolution {
|
|
ok: true;
|
|
/** The absolute, symlink-free path, or the nearest real ancestor plus the rest. */
|
|
path: string;
|
|
/** The nearest existing directory, which is what containment is judged against. */
|
|
anchor: string;
|
|
/** True when the path itself does not exist, so a create is what happens. */
|
|
missing: boolean;
|
|
}
|
|
export type ResolveResult = PathResolution | { ok: false; reason: string };
|
|
|
|
/**
|
|
* Turns a path the model wrote into a real one.
|
|
*
|
|
* The order matters and is the whole point: expand, resolve, then `realpath`, then
|
|
* judge containment. Checking containment before `realpath` would decide about a
|
|
* path by where it is written rather than where it lands, and a symlink inside a
|
|
* write root would then be a way to write anywhere.
|
|
*/
|
|
export async function resolvePath(written: unknown, policy: PathPolicy): Promise<ResolveResult> {
|
|
if (typeof written !== "string" || written.trim().length === 0) {
|
|
return { ok: false, reason: "A path is required, as a string." };
|
|
}
|
|
if (written.includes("\0")) {
|
|
return { ok: false, reason: "That path contains a null byte, which no real path has." };
|
|
}
|
|
|
|
const expanded = expandHome(written.trim(), policy.home);
|
|
// A relative path is read against home rather than the working directory: a
|
|
// launcher command has no meaningful working directory.
|
|
const absolute = path.resolve(policy.home, expanded);
|
|
const { real, anchor, missing } = await realpathChain(absolute);
|
|
|
|
return { ok: true, path: real, anchor, missing };
|
|
}
|
|
|
|
async function realpathChain(absolute: string): Promise<{ real: string; anchor: string; missing: boolean }> {
|
|
const trailing: string[] = [];
|
|
let current = absolute;
|
|
|
|
for (;;) {
|
|
try {
|
|
const real = await realpath(current);
|
|
return {
|
|
real: path.join(real, ...trailing.reverse()),
|
|
anchor: real,
|
|
missing: trailing.length > 0,
|
|
};
|
|
} catch {
|
|
const parent = path.dirname(current);
|
|
if (parent === current) return { real: absolute, anchor: absolute, missing: true };
|
|
trailing.push(path.basename(current));
|
|
current = parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Why a path may not be read, or undefined when it may. */
|
|
export function denialFor(resolved: string, policy: PathPolicy): string | undefined {
|
|
for (const directory of DENIED_DIRECTORIES) {
|
|
if (isWithin(resolved, path.join(policy.home, directory))) {
|
|
return `it is inside ~/${directory}, which holds credentials`;
|
|
}
|
|
}
|
|
|
|
for (const file of DENIED_FILES) {
|
|
if (resolved === path.join(policy.home, file)) return `it is ~/${file}, which holds credentials`;
|
|
}
|
|
|
|
const base = path.basename(resolved);
|
|
|
|
if (DENIED_BASENAMES.includes(base)) return `its name, ${base}, marks it as a credential file`;
|
|
|
|
const extension = path.extname(base).toLowerCase();
|
|
if (DENIED_EXTENSIONS.includes(extension)) {
|
|
return `it is a ${extension} file, which is key material`;
|
|
}
|
|
|
|
if (DENIED_PREFIXES.some((prefix) => base === prefix || base.startsWith(`${prefix}.`))) {
|
|
return `its name, ${base}, marks it as an environment file`;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/** Why a path may not be read, or undefined when it may. */
|
|
export function readDenial(resolved: string, policy: PathPolicy): string | undefined {
|
|
const denied = denialFor(resolved, policy);
|
|
if (denied) return denied;
|
|
|
|
const allowed = policy.readRoots.some((root) => isWithin(resolved, root));
|
|
return allowed
|
|
? undefined
|
|
: `it is outside the paths Lam may read (${describeRoots(policy.readRoots, policy.home)})`;
|
|
}
|
|
|
|
/**
|
|
* Why a path may not be changed, or undefined when it may.
|
|
*
|
|
* Deliberately does not consult the denylist: those rules protect against
|
|
* disclosure, and a write discloses nothing. A write to `~/Downloads/report.pem`
|
|
* is allowed, and the dialog names the path it is about to replace.
|
|
*/
|
|
export function writeDenial(resolved: string, policy: PathPolicy): string | undefined {
|
|
const allowed = policy.writeRoots.some((root) => isWithin(resolved, root));
|
|
return allowed
|
|
? undefined
|
|
: `it is outside the paths Lam may change (${describeRoots(policy.writeRoots, policy.home)})`;
|
|
}
|
|
|
|
/** Shortens a path to its `~` form for anything a person reads. */
|
|
export function displayPath(resolved: string, home: string): string {
|
|
if (resolved === home) return "~";
|
|
if (isWithin(resolved, home)) return `~/${path.relative(home, resolved)}`;
|
|
return resolved;
|
|
}
|
|
|
|
/** "~, /Volumes/Archive", for a refusal the model can act on. */
|
|
function describeRoots(roots: string[], home: string): string {
|
|
return roots.map((root) => displayPath(root, home)).join(", ");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/files.test.ts`
|
|
Expected: PASS, 22 tests.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/lib/constants.ts src/lib/files.ts src/__tests__/files.test.ts
|
|
git commit -m "feat: path policy for file access"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: The read operations
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/files.ts` (append)
|
|
- Modify: `src/__tests__/files.test.ts` (append)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/files.test.ts`:
|
|
|
|
```ts
|
|
describe("listDirectory", () => {
|
|
let home: string;
|
|
let policy: PathPolicy;
|
|
|
|
beforeAll(async () => {
|
|
home = await mkdtemp(path.join(tmpdir(), "lam-list-"));
|
|
policy = createPolicy({ home });
|
|
await mkdir(path.join(home, "Downloads", "reports"), { recursive: true });
|
|
await writeFile(path.join(home, "Downloads", "report.md"), "hello\n");
|
|
await writeFile(path.join(home, "Downloads", ".hidden"), "x\n");
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(home, { recursive: true, force: true });
|
|
});
|
|
|
|
it("lists directories first, then files, hiding dotfiles by default", async () => {
|
|
const result = await listDirectory(path.join(home, "Downloads"), policy);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
expect(result.content).toContain("reports/ (directory)");
|
|
expect(result.content).toContain("report.md (");
|
|
expect(result.content).not.toContain(".hidden");
|
|
expect(result.content.indexOf("reports/")).toBeLessThan(result.content.indexOf("report.md"));
|
|
});
|
|
|
|
it("includes dotfiles when asked", async () => {
|
|
const result = await listDirectory(path.join(home, "Downloads"), policy, { hidden: true });
|
|
expect(result.ok && result.content).toContain(".hidden");
|
|
});
|
|
|
|
it("refuses a directory outside the read roots", async () => {
|
|
const result = await listDirectory("/etc", policy);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.refused).toBe("policy");
|
|
expect(result.content).toContain("outside the paths");
|
|
});
|
|
|
|
it("refuses a credential directory", async () => {
|
|
const result = await listDirectory(path.join(home, ".ssh"), policy);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.content).toContain("credentials");
|
|
});
|
|
|
|
it("says so when the path is a file", async () => {
|
|
const result = await listDirectory(path.join(home, "Downloads", "report.md"), policy);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.content).toContain("read_file");
|
|
});
|
|
|
|
it("states the cap instead of truncating silently", async () => {
|
|
const many = path.join(home, "Downloads", "many");
|
|
await mkdir(many, { recursive: true });
|
|
await Promise.all(
|
|
Array.from({ length: 210 }, (_, index) => writeFile(path.join(many, `f${index}.txt`), "x")),
|
|
);
|
|
|
|
const result = await listDirectory(many, policy);
|
|
expect(result.ok && result.content).toContain("and 10 more");
|
|
});
|
|
});
|
|
|
|
describe("readTextFile", () => {
|
|
let home: string;
|
|
let policy: PathPolicy;
|
|
|
|
beforeAll(async () => {
|
|
home = await mkdtemp(path.join(tmpdir(), "lam-read-"));
|
|
policy = createPolicy({ home });
|
|
await writeFile(
|
|
path.join(home, "lines.txt"),
|
|
Array.from({ length: 300 }, (_, index) => `line ${index + 1}`).join("\n"),
|
|
);
|
|
await writeFile(path.join(home, "binary.bin"), Buffer.from([0x00, 0x01, 0x02, 0x00]));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(home, { recursive: true, force: true });
|
|
});
|
|
|
|
it("numbers the lines it returns", async () => {
|
|
const result = await readTextFile({ path: path.join(home, "lines.txt") }, policy);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
expect(result.content).toContain("1\tline 1");
|
|
expect(result.content).toContain("200\tline 200");
|
|
expect(result.content).not.toContain("line 201");
|
|
// The reader has to be able to tell there is more, and where to continue.
|
|
expect(result.content).toContain("of 300");
|
|
expect(result.content).toContain("offset 200");
|
|
});
|
|
|
|
it("continues from an offset", async () => {
|
|
const result = await readTextFile({ path: path.join(home, "lines.txt"), offset: 200, limit: 10 }, policy);
|
|
expect(result.ok && result.content).toContain("201\tline 201");
|
|
expect(result.ok && result.content).not.toContain("211\tline 211");
|
|
});
|
|
|
|
it("refuses a binary file with its size rather than returning mojibake", async () => {
|
|
const result = await readTextFile({ path: path.join(home, "binary.bin") }, policy);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.content).toContain("binary");
|
|
expect(result.content).toContain("bytes");
|
|
});
|
|
|
|
it("reports a missing file as a missing file", async () => {
|
|
const result = await readTextFile({ path: path.join(home, "nope.txt") }, policy);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.refused).toBe("io");
|
|
expect(result.content).toContain("nope.txt");
|
|
});
|
|
|
|
it("refuses a credential file", async () => {
|
|
const result = await readTextFile({ path: path.join(home, ".ssh", "id_rsa") }, policy);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.refused).toBe("policy");
|
|
});
|
|
});
|
|
|
|
describe("searchFiles", () => {
|
|
let home: string;
|
|
let policy: PathPolicy;
|
|
|
|
beforeAll(async () => {
|
|
home = await mkdtemp(path.join(tmpdir(), "lam-search-"));
|
|
policy = createPolicy({ home });
|
|
await mkdir(path.join(home, "Downloads", "deep", "deeper"), { recursive: true });
|
|
await writeFile(path.join(home, "Downloads", "notes.md"), "the deploy token is here\n");
|
|
await writeFile(path.join(home, "Downloads", "other.txt"), "nothing to see\n");
|
|
await writeFile(path.join(home, "Downloads", "deep", "deeper", "notes.md"), "x\n");
|
|
await mkdir(path.join(home, ".ssh"), { recursive: true });
|
|
await writeFile(path.join(home, ".ssh", "notes.md"), "id_rsa\n");
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(home, { recursive: true, force: true });
|
|
});
|
|
|
|
it("finds files by a name pattern", async () => {
|
|
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "*.md" }, policy);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
expect(result.content).toContain("notes.md");
|
|
expect(result.content).not.toContain("other.txt");
|
|
});
|
|
|
|
it("matches content when asked, and only returns the files that match", async () => {
|
|
const result = await searchFiles(
|
|
{ path: path.join(home, "Downloads"), pattern: "*", contains: "deploy token" },
|
|
policy,
|
|
);
|
|
expect(result.ok && result.content).toContain("notes.md");
|
|
expect(result.ok && result.content).not.toContain("other.txt");
|
|
});
|
|
|
|
it("never returns a credential store, even by name", async () => {
|
|
const result = await searchFiles({ path: home, pattern: "notes.md" }, policy);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
expect(result.content).not.toContain(".ssh");
|
|
expect(result.content).toContain("Downloads/notes.md");
|
|
});
|
|
|
|
it("treats a pattern with no regular-expression meaning as a literal", async () => {
|
|
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "notes.md" }, policy);
|
|
expect(result.ok && result.content).toContain("notes.md");
|
|
});
|
|
|
|
it("says nothing was found rather than failing", async () => {
|
|
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "*.pdf" }, policy);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.ok && result.content).toContain("No files");
|
|
});
|
|
});
|
|
```
|
|
|
|
Add `listDirectory, readTextFile, searchFiles` to the import list at the top of the file.
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/files.test.ts`
|
|
Expected: FAIL, `listDirectory is not a function`.
|
|
|
|
- [ ] **Step 3: Write the read operations**
|
|
|
|
Append to `src/lib/files.ts`:
|
|
|
|
```ts
|
|
export interface FileOk {
|
|
ok: true;
|
|
content: string;
|
|
path: string;
|
|
bytes?: number;
|
|
}
|
|
export interface FileRefused {
|
|
ok: false;
|
|
/**
|
|
* `policy` is "there is nothing here you are allowed to touch", `approval` is "the
|
|
* user said no to this one", `io` is the disk saying no. The three are kept apart
|
|
* because the model should stop trying in the first case, ask rather than retry in
|
|
* the second, and can often route around the third.
|
|
*/
|
|
refused: "policy" | "approval" | "io";
|
|
content: string;
|
|
}
|
|
export type FileResult = FileOk | FileRefused;
|
|
|
|
/** The approval callback the mutating operations call. Supplied by `approval.ts`. */
|
|
export type ApproveFn = (request: ApprovalRequest) => Promise<boolean>;
|
|
|
|
export type FileOperation = "create" | "overwrite" | "edit" | "delete" | "move";
|
|
|
|
/**
|
|
* What an operation wants to do, as data.
|
|
*
|
|
* Describes the request rather than the dialog, so this file stays free of UI and
|
|
* the wording lives in `approval.ts`. The resolved path is separate from the display
|
|
* form because the ledger remembers the real path while the user reads the short one.
|
|
*/
|
|
export interface ApprovalRequest {
|
|
operation: FileOperation;
|
|
/** The resolved path, which is what the ledger keys on. */
|
|
path: string;
|
|
/** The path as the user should read it, tilde shortened. */
|
|
display: string;
|
|
/** The thing the dialog names, such as a basename, or "a.txt to b.txt". */
|
|
subject: string;
|
|
/** Phrases describing what changes, already written out. */
|
|
details: string[];
|
|
/** A delete or a move asks every time, because it cannot be undone (A2). */
|
|
irreversible: boolean;
|
|
}
|
|
|
|
function refusal(refused: FileRefused["refused"], content: string): FileRefused {
|
|
return { ok: false, refused, content };
|
|
}
|
|
|
|
/** The model never sees a stack trace: an OS error is one sentence about one path. */
|
|
function ioFailure(error: unknown, display: string): FileRefused {
|
|
const code = (error as { code?: string })?.code;
|
|
const message = (error as { message?: string })?.message ?? "unknown error";
|
|
|
|
if (code === "ENOENT") return refusal("io", `${display} does not exist.`);
|
|
if (code === "EACCES" || code === "EPERM") {
|
|
return refusal("io", `${display} was refused by the operating system (permission denied).`);
|
|
}
|
|
if (code === "EISDIR") return refusal("io", `${display} is a directory, not a file.`);
|
|
if (code === "ENOTDIR") return refusal("io", `${display} is not a directory.`);
|
|
if (code === "ENOTEMPTY") return refusal("io", `${display} is not empty.`);
|
|
|
|
return refusal("io", `${display} could not be read: ${message}`);
|
|
}
|
|
|
|
/** "1.2 KB", so a size in a dialog is a number a person can judge. */
|
|
export function formatBytes(bytes: number): string {
|
|
if (bytes < 1000) return `${bytes} bytes`;
|
|
if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(1)} KB`;
|
|
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
}
|
|
|
|
/** "3 days ago", for a modification time. */
|
|
export function describeAge(changed: Date, now: Date): string {
|
|
const seconds = Math.max(0, Math.round((now.getTime() - changed.getTime()) / 1000));
|
|
if (seconds < 60) return "just now";
|
|
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
|
|
const days = Math.round(hours / 24);
|
|
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
|
|
return changed.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export async function listDirectory(
|
|
written: string,
|
|
policy: PathPolicy,
|
|
options: { hidden?: boolean; now?: Date } = {},
|
|
): Promise<FileResult> {
|
|
const resolved = await resolvePath(written, policy);
|
|
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
|
|
|
const display = displayPath(resolved.path, policy.home);
|
|
const denied = readDenial(resolved.path, policy);
|
|
if (denied) return refusal("policy", `Refused: ${display} cannot be read because ${denied}.`);
|
|
|
|
const details = await stat(resolved.path).catch(() => undefined);
|
|
if (!details) return refusal("io", `${display} does not exist.`);
|
|
if (!details.isDirectory()) {
|
|
return refusal("io", `${display} is a file, not a directory. Use read_file to read it.`);
|
|
}
|
|
|
|
const entries = await readdir(resolved.path, { withFileTypes: true }).catch((error) => error);
|
|
if (!Array.isArray(entries)) return ioFailure(entries, display);
|
|
|
|
const visible = options.hidden ? entries : entries.filter((entry) => !entry.name.startsWith("."));
|
|
|
|
const rows = await Promise.all(
|
|
visible.map(async (entry) => {
|
|
const full = path.join(resolved.path, entry.name);
|
|
const info = await stat(full).catch(() => undefined);
|
|
return {
|
|
directory: entry.isDirectory(),
|
|
name: entry.name,
|
|
size: info?.size ?? 0,
|
|
changed: info?.mtime,
|
|
};
|
|
}),
|
|
);
|
|
|
|
rows.sort((left, right) => {
|
|
if (left.directory !== right.directory) return left.directory ? -1 : 1;
|
|
return left.name.localeCompare(right.name);
|
|
});
|
|
|
|
const now = options.now ?? new Date();
|
|
const shown = rows.slice(0, MAX_LIST_ENTRIES);
|
|
const lines = shown.map((row) => {
|
|
if (row.directory) return `${row.name}/ (directory)`;
|
|
const age = row.changed ? `, changed ${describeAge(row.changed, now)}` : "";
|
|
return `${row.name} (${formatBytes(row.size)}${age})`;
|
|
});
|
|
|
|
const more =
|
|
rows.length > shown.length
|
|
? `\n\nShowing ${shown.length} of ${rows.length} entries, and ${rows.length - shown.length} more.`
|
|
: "";
|
|
|
|
return {
|
|
ok: true,
|
|
path: resolved.path,
|
|
content: `${display} holds ${rows.length} entr${rows.length === 1 ? "y" : "ies"}:\n\n${lines
|
|
.map((line) => `- ${line}`)
|
|
.join("\n")}${more}`,
|
|
};
|
|
}
|
|
|
|
export async function readTextFile(
|
|
args: { path: string; offset?: number; limit?: number },
|
|
policy: PathPolicy,
|
|
): Promise<FileResult> {
|
|
const resolved = await resolvePath(args.path, policy);
|
|
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
|
|
|
const display = displayPath(resolved.path, policy.home);
|
|
const denied = readDenial(resolved.path, policy);
|
|
if (denied) return refusal("policy", `Refused: ${display} cannot be read because ${denied}.`);
|
|
|
|
const details = await stat(resolved.path).catch(() => undefined);
|
|
if (!details) return refusal("io", `${display} does not exist.`);
|
|
if (details.isDirectory()) {
|
|
return refusal("io", `${display} is a directory. Use list_directory to see what is in it.`);
|
|
}
|
|
if (details.size > MAX_FILE_BYTES) {
|
|
return refusal(
|
|
"io",
|
|
`${display} is ${formatBytes(details.size)}, too large to read in one go (the limit is ${formatBytes(MAX_FILE_BYTES)}).`,
|
|
);
|
|
}
|
|
|
|
const bytes = await readFile(resolved.path).catch((error) => error);
|
|
if (!Buffer.isBuffer(bytes)) return ioFailure(bytes, display);
|
|
|
|
// A NUL byte in the first stretch is what separates text from everything else,
|
|
// and detecting it here is why a PNG never comes back as mojibake.
|
|
if (bytes.subarray(0, 8192).includes(0)) {
|
|
return refusal(
|
|
"io",
|
|
`${display} is binary (${formatBytes(bytes.length)}), not text, so Lam will not print it.`,
|
|
);
|
|
}
|
|
|
|
const lines = bytes.toString("utf8").split("\n");
|
|
const offset = Math.max(0, (args.offset ?? 1) - 1);
|
|
const limit = Math.max(1, args.limit ?? MAX_READ_LINES);
|
|
|
|
const numbered: string[] = [];
|
|
let characters = 0;
|
|
let consumed = offset;
|
|
|
|
for (let index = offset; index < lines.length && numbered.length < limit; index++) {
|
|
const line = `${index + 1}\t${lines[index]}`;
|
|
// The character cap is the second of two limits and is reported as such, so a
|
|
// long file is never presented as if it were complete.
|
|
if (characters + line.length > MAX_READ_CHARS) break;
|
|
characters += line.length;
|
|
numbered.push(line);
|
|
consumed = index + 1;
|
|
}
|
|
|
|
const head =
|
|
consumed < lines.length
|
|
? `${display} (showing lines ${offset + 1} to ${consumed} of ${lines.length}; continue with offset ${consumed + 1}):\n\n`
|
|
: `${display} (${lines.length} lines):\n\n`;
|
|
|
|
return {
|
|
ok: true,
|
|
path: resolved.path,
|
|
bytes: bytes.length,
|
|
content: head + numbered.join("\n"),
|
|
};
|
|
}
|
|
|
|
export async function searchFiles(
|
|
args: { path: string; pattern: string; contains?: string },
|
|
policy: PathPolicy,
|
|
): Promise<FileResult> {
|
|
const resolved = await resolvePath(args.path, policy);
|
|
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
|
|
|
const display = displayPath(resolved.path, policy.home);
|
|
const denied = readDenial(resolved.path, policy);
|
|
if (denied) return refusal("policy", `Refused: ${display} cannot be searched because ${denied}.`);
|
|
|
|
const matcher = globMatcher(args.pattern);
|
|
const needle = args.contains?.toLowerCase();
|
|
const found: string[] = [];
|
|
let capped = false;
|
|
|
|
const walk = async (directory: string, depth: number): Promise<void> => {
|
|
if (found.length >= MAX_SEARCH_RESULTS) {
|
|
capped = true;
|
|
return;
|
|
}
|
|
if (depth > MAX_SEARCH_DEPTH) return;
|
|
|
|
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
if (found.length >= MAX_SEARCH_RESULTS) {
|
|
capped = true;
|
|
return;
|
|
}
|
|
|
|
const full = path.join(directory, entry.name);
|
|
// Every candidate is judged by the same rules as a direct read, so a search
|
|
// can never be a way around the denylist.
|
|
if (readDenial(full, policy)) continue;
|
|
|
|
if (entry.isDirectory()) {
|
|
if (!SKIP_DIRECTORIES.includes(entry.name)) await walk(full, depth + 1);
|
|
continue;
|
|
}
|
|
|
|
if (!matcher(entry.name)) continue;
|
|
|
|
if (needle) {
|
|
const bytes = await readFile(full).catch(() => undefined);
|
|
if (!bytes) continue;
|
|
if (!bytes.subarray(0, MAX_SEARCH_BYTES).toString("utf8").toLowerCase().includes(needle)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
found.push(displayPath(full, policy.home));
|
|
}
|
|
};
|
|
|
|
const details = await stat(resolved.path).catch(() => undefined);
|
|
if (!details) return refusal("io", `${display} does not exist.`);
|
|
if (details.isDirectory()) await walk(resolved.path, 0);
|
|
else if (matcher(path.basename(resolved.path))) found.push(display);
|
|
|
|
if (found.length === 0) {
|
|
return { ok: true, path: resolved.path, content: `No files under ${display} matched.` };
|
|
}
|
|
|
|
const note = capped ? `\n\nStopped at ${MAX_SEARCH_RESULTS} matches.` : "";
|
|
return {
|
|
ok: true,
|
|
path: resolved.path,
|
|
content: `${found.length} match${found.length === 1 ? "" : "es"} under ${display}:\n\n${found
|
|
.map((entry) => `- ${entry}`)
|
|
.join("\n")}${note}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A glob matcher for `*`, `?` and `**`.
|
|
*
|
|
* Written here rather than pulled in as a dependency: it is a dozen lines, and the
|
|
* only thing it is ever handed is a filename pattern from a model or from the user.
|
|
*/
|
|
export function globMatcher(pattern: string): (name: string) => boolean {
|
|
const escaped = pattern
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\*\*/g, "\u0000")
|
|
.replace(/\*/g, "[^/]*")
|
|
.replace(/\?/g, "[^/]")
|
|
.replace(/\u0000/g, ".*");
|
|
|
|
const expression = new RegExp(`^${escaped}$`, "i");
|
|
return (name: string) => expression.test(name);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/files.test.ts`
|
|
Expected: PASS, 39 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/files.ts src/__tests__/files.test.ts
|
|
git commit -m "feat: read, list and search operations behind the policy"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: The write operations
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/files.ts` (append)
|
|
- Modify: `src/__tests__/files.test.ts` (append)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/files.test.ts`:
|
|
|
|
```ts
|
|
describe("the mutating operations", () => {
|
|
let home: string;
|
|
let policy: PathPolicy;
|
|
let asked: ApprovalRequest[];
|
|
const allow = async (request: ApprovalRequest) => {
|
|
asked.push(request);
|
|
return true;
|
|
};
|
|
const deny = async (request: ApprovalRequest) => {
|
|
asked.push(request);
|
|
return false;
|
|
};
|
|
|
|
beforeAll(() => {
|
|
// Every path the tests use sits under a fresh home, so nothing here can touch
|
|
// the machine the suite runs on.
|
|
policy = createPolicy({ home: "/Users/me", supportPath: "/Users/me/support" });
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(path.join(tmpdir(), "lam-write-"));
|
|
policy = createPolicy({ home });
|
|
asked = [];
|
|
await mkdir(path.join(home, "Downloads"), { recursive: true });
|
|
await writeFile(path.join(home, "Downloads", "report.md"), "one\ntwo\nthree\n");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(home, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates a file after approval and says what it wrote", async () => {
|
|
const result = await writeTextFile(
|
|
{ path: path.join(home, "Downloads", "new.md"), content: "hello\n" },
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(result.ok && result.content).toContain("Wrote");
|
|
expect(asked).toHaveLength(1);
|
|
expect(asked[0].operation).toBe("create");
|
|
expect(asked[0].details.join(" ")).toContain("New content is 6 bytes, 2 lines");
|
|
expect(await readFile(path.join(home, "Downloads", "new.md"), "utf8")).toBe("hello\n");
|
|
});
|
|
|
|
it("discloses what an overwrite replaces", async () => {
|
|
await writeTextFile(
|
|
{ path: path.join(home, "Downloads", "report.md"), content: "replaced\n" },
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(asked[0].operation).toBe("overwrite");
|
|
expect(asked[0].details.join(" ")).toContain("Replaces 14 bytes");
|
|
});
|
|
|
|
it("creates missing folders, and says so before it does", async () => {
|
|
await writeTextFile(
|
|
{ path: path.join(home, "Downloads", "reports", "may.md"), content: "x\n" },
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(asked[0].details.join(" ")).toContain("Creates the folder");
|
|
});
|
|
|
|
it("writes nothing when the user declines", async () => {
|
|
const result = await writeTextFile(
|
|
{ path: path.join(home, "Downloads", "new.md"), content: "hello\n" },
|
|
policy,
|
|
deny,
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.refused).toBe("approval");
|
|
expect(result.content).toContain("declined");
|
|
await expect(readFile(path.join(home, "Downloads", "new.md"))).rejects.toThrow();
|
|
});
|
|
|
|
it("never asks about a path outside the write roots", async () => {
|
|
const result = await writeTextFile(
|
|
{ path: path.join(home, "Documents", "taxes.md"), content: "x" },
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
expect(asked).toHaveLength(0);
|
|
});
|
|
|
|
it("edits a unique occurrence and reports the count", async () => {
|
|
const result = await editTextFile(
|
|
{ path: path.join(home, "Downloads", "report.md"), oldString: "two", newString: "TWO" },
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(result.ok && result.content).toContain("1 occurrence");
|
|
expect(await readFile(path.join(home, "Downloads", "report.md"), "utf8")).toContain("TWO");
|
|
});
|
|
|
|
it("refuses an ambiguous edit rather than guessing which one", async () => {
|
|
await writeFile(path.join(home, "Downloads", "twice.md"), "same\nsame\n");
|
|
const result = await editTextFile(
|
|
{ path: path.join(home, "Downloads", "twice.md"), oldString: "same", newString: "other" },
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.content).toContain("2 times");
|
|
expect(result.content).toContain("all");
|
|
expect(asked).toHaveLength(0);
|
|
});
|
|
|
|
it("replaces every occurrence when asked to", async () => {
|
|
await writeFile(path.join(home, "Downloads", "twice.md"), "same\nsame\n");
|
|
const result = await editTextFile(
|
|
{
|
|
path: path.join(home, "Downloads", "twice.md"),
|
|
oldString: "same",
|
|
newString: "other",
|
|
all: true,
|
|
},
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(result.ok && result.content).toContain("2 occurrences");
|
|
});
|
|
|
|
it("refuses an edit whose text is not there", async () => {
|
|
const result = await editTextFile(
|
|
{ path: path.join(home, "Downloads", "report.md"), oldString: "absent", newString: "x" },
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.content).toContain("not found");
|
|
});
|
|
|
|
it("deletes a file after approval, and says it cannot be undone", async () => {
|
|
const result = await deletePath(path.join(home, "Downloads", "report.md"), policy, allow);
|
|
expect(result.ok && result.content).toContain("Deleted");
|
|
expect(asked[0].operation).toBe("delete");
|
|
expect(asked[0].irreversible).toBe(true);
|
|
await expect(readFile(path.join(home, "Downloads", "report.md"))).rejects.toThrow();
|
|
});
|
|
|
|
it("refuses a directory that has anything in it", async () => {
|
|
await mkdir(path.join(home, "Downloads", "stuff"));
|
|
await writeFile(path.join(home, "Downloads", "stuff", "a.md"), "x");
|
|
const result = await deletePath(path.join(home, "Downloads", "stuff"), policy, allow);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.content).toContain("1 entry");
|
|
expect(result.content).toContain("recursively");
|
|
expect(asked).toHaveLength(0);
|
|
});
|
|
|
|
it("deletes an empty directory", async () => {
|
|
await mkdir(path.join(home, "Downloads", "empty"));
|
|
const result = await deletePath(path.join(home, "Downloads", "empty"), policy, allow);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("moves a file and names both ends in the request", async () => {
|
|
const result = await movePath(
|
|
{
|
|
from: path.join(home, "Downloads", "report.md"),
|
|
to: path.join(home, "Downloads", "renamed.md"),
|
|
},
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(result.ok && result.content).toContain("Moved");
|
|
expect(asked[0].subject).toBe("report.md to renamed.md");
|
|
expect(asked[0].irreversible).toBe(true);
|
|
expect(await readFile(path.join(home, "Downloads", "renamed.md"), "utf8")).toContain("one");
|
|
});
|
|
|
|
it("refuses to move onto an existing path", async () => {
|
|
await writeFile(path.join(home, "Downloads", "other.md"), "keep me\n");
|
|
const result = await movePath(
|
|
{
|
|
from: path.join(home, "Downloads", "report.md"),
|
|
to: path.join(home, "Downloads", "other.md"),
|
|
},
|
|
policy,
|
|
allow,
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.content).toContain("already exists");
|
|
expect(await readFile(path.join(home, "Downloads", "other.md"), "utf8")).toBe("keep me\n");
|
|
});
|
|
|
|
it("refuses to move into a path it may not write", async () => {
|
|
const result = await movePath(
|
|
{
|
|
from: path.join(home, "Downloads", "report.md"),
|
|
to: path.join(home, "Documents", "report.md"),
|
|
},
|
|
policy,
|
|
allow,
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
expect(asked).toHaveLength(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
Add `ApprovalRequest, deletePath, editTextFile, movePath, writeTextFile` to the import list, and `beforeEach` to the vitest import.
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/files.test.ts`
|
|
Expected: FAIL, `writeTextFile is not a function`.
|
|
|
|
- [ ] **Step 3: Write the mutating operations**
|
|
|
|
Append to `src/lib/files.ts`, and extend the `node:fs/promises` import to include `mkdir, rename, rmdir, unlink, writeFile`:
|
|
|
|
```ts
|
|
function lineCount(text: string): number {
|
|
return text.length === 0 ? 0 : text.split("\n").length;
|
|
}
|
|
|
|
/** One line of text, cut to a length a dialog can show. */
|
|
export function oneLine(text: string, limit = 200): string {
|
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
return collapsed.length > limit ? `${collapsed.slice(0, limit)} …` : collapsed;
|
|
}
|
|
|
|
type PreparedWrite = { ok: true; resolved: PathResolution; display: string };
|
|
|
|
/** Resolve, then the write check. The shared opening move of every mutating call. */
|
|
async function prepareWrite(written: string, policy: PathPolicy): Promise<PreparedWrite | FileRefused> {
|
|
const resolved = await resolvePath(written, policy);
|
|
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
|
|
|
const display = displayPath(resolved.path, policy.home);
|
|
const denied = writeDenial(resolved.path, policy);
|
|
if (denied) return refusal("policy", `Refused: ${display} cannot be changed because ${denied}.`);
|
|
|
|
return { ok: true, resolved, display };
|
|
}
|
|
|
|
function declined(display: string): FileRefused {
|
|
return refusal(
|
|
"approval",
|
|
`Refused: the user declined to change ${display}. Nothing was written. Ask first rather than trying again.`,
|
|
);
|
|
}
|
|
|
|
export async function writeTextFile(
|
|
args: { path: string; content: string },
|
|
policy: PathPolicy,
|
|
approve: ApproveFn,
|
|
options: { now?: Date } = {},
|
|
): Promise<FileResult> {
|
|
const prepared = await prepareWrite(args.path, policy);
|
|
if (!prepared.ok) return prepared;
|
|
|
|
const { resolved, display } = prepared;
|
|
const existing = resolved.missing ? undefined : await stat(resolved.path).catch(() => undefined);
|
|
if (existing?.isDirectory()) {
|
|
return refusal("io", `${display} is a directory, and Lam does not overwrite one with a file.`);
|
|
}
|
|
|
|
const content = typeof args.content === "string" ? args.content : "";
|
|
const parent = path.dirname(resolved.path);
|
|
const details: string[] = [];
|
|
|
|
if (parent !== resolved.anchor) {
|
|
details.push(`Creates the folder ${displayPath(parent, policy.home)} as well.`);
|
|
}
|
|
if (existing) {
|
|
const age = describeAge(existing.mtime, options.now ?? new Date());
|
|
details.push(`Replaces ${formatBytes(existing.size)}, last changed ${age}.`);
|
|
}
|
|
details.push(
|
|
`New content is ${formatBytes(Buffer.byteLength(content, "utf8"))}, ${lineCount(content)} lines.`,
|
|
);
|
|
|
|
const request: ApprovalRequest = {
|
|
operation: existing ? "overwrite" : "create",
|
|
path: resolved.path,
|
|
display,
|
|
subject: path.basename(resolved.path),
|
|
details,
|
|
irreversible: false,
|
|
};
|
|
if (!(await approve(request))) return declined(display);
|
|
|
|
try {
|
|
await mkdir(parent, { recursive: true });
|
|
await writeFile(resolved.path, content, "utf8");
|
|
} catch (error) {
|
|
return ioFailure(error, display);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
path: resolved.path,
|
|
bytes: Buffer.byteLength(content, "utf8"),
|
|
content: `${existing ? "Overwrote" : "Wrote"} ${display} (${formatBytes(
|
|
Buffer.byteLength(content, "utf8"),
|
|
)}, ${lineCount(content)} lines).`,
|
|
};
|
|
}
|
|
|
|
export async function editTextFile(
|
|
args: { path: string; oldString: string; newString: string; all?: boolean },
|
|
policy: PathPolicy,
|
|
approve: ApproveFn,
|
|
): Promise<FileResult> {
|
|
const prepared = await prepareWrite(args.path, policy);
|
|
if (!prepared.ok) return prepared;
|
|
|
|
const { resolved, display } = prepared;
|
|
const details = await stat(resolved.path).catch(() => undefined);
|
|
if (!details) return refusal("io", `${display} does not exist, so there is nothing to edit.`);
|
|
if (details.isDirectory()) return refusal("io", `${display} is a directory.`);
|
|
|
|
if (!args.oldString) return refusal("io", "edit_file needs an old_string to replace.");
|
|
if (args.oldString === args.newString) {
|
|
return refusal("io", "old_string and new_string are identical, so nothing would change.");
|
|
}
|
|
|
|
const before = await readFile(resolved.path, "utf8").catch((error) => error);
|
|
if (typeof before !== "string") return ioFailure(before, display);
|
|
|
|
const occurrences = before.split(args.oldString).length - 1;
|
|
if (occurrences === 0) {
|
|
return refusal(
|
|
"io",
|
|
`${display} does not contain that text, so nothing was changed. Read the file again and copy the exact characters, including indentation.`,
|
|
);
|
|
}
|
|
if (occurrences > 1 && !args.all) {
|
|
return refusal(
|
|
"io",
|
|
`That text appears ${occurrences} times in ${display}, so nothing was changed. Include more surrounding text to make it unique, or pass all: true to replace every one.`,
|
|
);
|
|
}
|
|
|
|
const request: ApprovalRequest = {
|
|
operation: "edit",
|
|
path: resolved.path,
|
|
display,
|
|
subject: path.basename(resolved.path),
|
|
details: [
|
|
`Replaces ${occurrences} occurrence${occurrences === 1 ? "" : "s"}.`,
|
|
`- ${oneLine(args.oldString)}`,
|
|
`+ ${oneLine(args.newString)}`,
|
|
],
|
|
irreversible: false,
|
|
};
|
|
if (!(await approve(request))) return declined(display);
|
|
|
|
const after = args.all
|
|
? before.split(args.oldString).join(args.newString)
|
|
: before.replace(args.oldString, args.newString);
|
|
|
|
try {
|
|
await writeFile(resolved.path, after, "utf8");
|
|
} catch (error) {
|
|
return ioFailure(error, display);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
path: resolved.path,
|
|
content: `Edited ${display}: replaced ${occurrences} occurrence${
|
|
occurrences === 1 ? "" : "s"
|
|
}. The file is now ${formatBytes(Buffer.byteLength(after, "utf8"))}, ${lineCount(after)} lines.`,
|
|
};
|
|
}
|
|
|
|
export async function deletePath(
|
|
written: string,
|
|
policy: PathPolicy,
|
|
approve: ApproveFn,
|
|
): Promise<FileResult> {
|
|
const prepared = await prepareWrite(written, policy);
|
|
if (!prepared.ok) return prepared;
|
|
|
|
const { resolved, display } = prepared;
|
|
const details = await stat(resolved.path).catch(() => undefined);
|
|
if (!details) return refusal("io", `${display} does not exist, so there is nothing to delete.`);
|
|
|
|
const entries = details.isDirectory()
|
|
? await readdir(resolved.path).catch(() => [] as string[])
|
|
: undefined;
|
|
|
|
// A directory with anything in it is refused outright rather than recursed into:
|
|
// this is the one call where a misread path destroys an unbounded amount of work.
|
|
if (entries && entries.length > 0) {
|
|
return refusal(
|
|
"io",
|
|
`Refused: ${display} is a directory with ${entries.length} entr${
|
|
entries.length === 1 ? "y" : "ies"
|
|
}, and Lam does not delete directories recursively. List it and delete what you actually need.`,
|
|
);
|
|
}
|
|
|
|
const request: ApprovalRequest = {
|
|
operation: "delete",
|
|
path: resolved.path,
|
|
display,
|
|
subject: path.basename(resolved.path),
|
|
details: [
|
|
details.isDirectory()
|
|
? "This is an empty directory."
|
|
: `${formatBytes(details.size)}, last changed ${describeAge(details.mtime, new Date())}.`,
|
|
"This cannot be undone.",
|
|
],
|
|
irreversible: true,
|
|
};
|
|
if (!(await approve(request))) return declined(display);
|
|
|
|
try {
|
|
if (details.isDirectory()) await rmdir(resolved.path);
|
|
else await unlink(resolved.path);
|
|
} catch (error) {
|
|
return ioFailure(error, display);
|
|
}
|
|
|
|
return { ok: true, path: resolved.path, content: `Deleted ${display}.` };
|
|
}
|
|
|
|
export async function movePath(
|
|
args: { from: string; to: string },
|
|
policy: PathPolicy,
|
|
approve: ApproveFn,
|
|
): Promise<FileResult> {
|
|
const source = await prepareWrite(args.from, policy);
|
|
if (!source.ok) return source;
|
|
|
|
const target = await prepareWrite(args.to, policy);
|
|
if (!target.ok) return target;
|
|
|
|
const details = await stat(source.resolved.path).catch(() => undefined);
|
|
if (!details) return refusal("io", `${source.display} does not exist, so there is nothing to move.`);
|
|
|
|
const occupied = await stat(target.resolved.path).catch(() => undefined);
|
|
if (occupied) {
|
|
return refusal(
|
|
"io",
|
|
`Refused: ${target.display} already exists. Nothing was moved, and Lam does not overwrite a file to move another one onto it.`,
|
|
);
|
|
}
|
|
|
|
const request: ApprovalRequest = {
|
|
operation: "move",
|
|
path: source.resolved.path,
|
|
display: `${source.display} to ${target.display}`,
|
|
subject: `${path.basename(source.resolved.path)} to ${path.basename(target.resolved.path)}`,
|
|
details: [
|
|
details.isDirectory()
|
|
? "This is a directory, and everything in it moves with it."
|
|
: `Moves ${formatBytes(details.size)}, last changed ${describeAge(details.mtime, new Date())}.`,
|
|
"This cannot be undone.",
|
|
],
|
|
irreversible: true,
|
|
};
|
|
if (!(await approve(request))) return declined(request.display);
|
|
|
|
try {
|
|
await mkdir(path.dirname(target.resolved.path), { recursive: true });
|
|
await rename(source.resolved.path, target.resolved.path);
|
|
} catch (error) {
|
|
return ioFailure(error, source.display);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
path: target.resolved.path,
|
|
content: `Moved ${source.display} to ${target.display}.`,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/files.test.ts`
|
|
Expected: PASS, 54 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/files.ts src/__tests__/files.test.ts
|
|
git commit -m "feat: write, edit, delete and move behind the policy and an approval"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: The approval dialog and its ledger
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/lib/approval.ts`
|
|
- Create: `src/__tests__/approval.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `src/__tests__/approval.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, it } from "vitest";
|
|
import { createApprovalLedger, describeApproval, type FileApproval } from "../lib/approval";
|
|
import type { ApprovalRequest } from "../lib/files";
|
|
|
|
const request = (overrides: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
|
|
operation: "overwrite",
|
|
path: "/Users/me/Downloads/report.md",
|
|
display: "~/Downloads/report.md",
|
|
subject: "report.md",
|
|
details: ["Replaces 14 bytes, last changed 3 days ago."],
|
|
irreversible: false,
|
|
...overrides,
|
|
});
|
|
|
|
describe("describeApproval", () => {
|
|
it("names the operation and the file", () => {
|
|
expect(describeApproval(request()).title).toBe("Overwrite report.md?");
|
|
expect(describeApproval(request({ operation: "create" })).title).toBe("Create report.md?");
|
|
expect(describeApproval(request({ operation: "edit" })).title).toBe("Edit report.md?");
|
|
});
|
|
|
|
it("shows the concrete path and every detail line", () => {
|
|
const approval = describeApproval(request());
|
|
expect(approval.body).toContain("~/Downloads/report.md");
|
|
expect(approval.body).toContain("Replaces 14 bytes");
|
|
});
|
|
|
|
it("marks a delete as destructive, and says it cannot be undone", () => {
|
|
const approval = describeApproval(
|
|
request({ operation: "delete", details: ["This cannot be undone."], irreversible: true }),
|
|
);
|
|
expect(approval.destructive).toBe(true);
|
|
expect(approval.body).toContain("cannot be undone");
|
|
});
|
|
|
|
it("names both ends of a move", () => {
|
|
expect(
|
|
describeApproval(
|
|
request({ operation: "move", display: "~/a.md to ~/Documents/b.md", subject: "a.md to b.md" }),
|
|
).title,
|
|
).toBe("Move a.md to b.md?");
|
|
});
|
|
});
|
|
|
|
describe("createApprovalLedger", () => {
|
|
it("asks once for a path and remembers the answer", async () => {
|
|
const asked: FileApproval[] = [];
|
|
const ledger = createApprovalLedger(async (approval) => {
|
|
asked.push(approval);
|
|
return true;
|
|
});
|
|
|
|
expect(await ledger.approve(request())).toBe(true);
|
|
expect(await ledger.approve(request())).toBe(true);
|
|
expect(asked).toHaveLength(1);
|
|
});
|
|
|
|
it("remembers nothing when the user declines, so the next attempt asks", async () => {
|
|
let calls = 0;
|
|
const ledger = createApprovalLedger(async () => {
|
|
calls++;
|
|
return false;
|
|
});
|
|
|
|
expect(await ledger.approve(request())).toBe(false);
|
|
expect(await ledger.approve(request())).toBe(false);
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it("treats two spellings of one path as one path", async () => {
|
|
// The ledger keys on the resolved path, so an edit that was approved for
|
|
// ~/Downloads/report.md also covers /Users/me/Downloads/report.md.
|
|
let calls = 0;
|
|
const ledger = createApprovalLedger(async () => {
|
|
calls++;
|
|
return true;
|
|
});
|
|
|
|
await ledger.approve(request());
|
|
await ledger.approve(request({ display: "/Users/me/Downloads/report.md" }));
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("asks every time for a delete or a move, even after an approval for the same path", async () => {
|
|
let calls = 0;
|
|
const ledger = createApprovalLedger(async () => {
|
|
calls++;
|
|
return true;
|
|
});
|
|
|
|
await ledger.approve(request());
|
|
await ledger.approve(request({ operation: "delete", irreversible: true }));
|
|
await ledger.approve(request({ operation: "delete", irreversible: true }));
|
|
expect(calls).toBe(3);
|
|
});
|
|
|
|
it("refuses when the dialog itself fails, rather than assuming consent", async () => {
|
|
// Fail closed: an unshowable dialog is not permission.
|
|
const ledger = createApprovalLedger(async () => {
|
|
throw new Error("no such widget");
|
|
});
|
|
expect(await ledger.approve(request())).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/approval.test.ts`
|
|
Expected: FAIL, `Failed to resolve import "../lib/approval"`.
|
|
|
|
- [ ] **Step 3: Write the module**
|
|
|
|
Create `src/lib/approval.ts`:
|
|
|
|
```ts
|
|
import { Alert, confirmAlert } from "@vicinae/api";
|
|
import type { ApprovalRequest, ApproveFn, FileOperation } from "./files";
|
|
|
|
/**
|
|
* The approval step, and the only file in this feature that imports Vicinae.
|
|
*
|
|
* Kept apart from `files.ts` on purpose: the policy there is a value and this is a
|
|
* dialog, so the boundary can be tested without a launcher and the wording can be
|
|
* changed without touching a path rule.
|
|
*/
|
|
|
|
export interface FileApproval {
|
|
title: string;
|
|
/** Every line the user needs: the path first, then what changes. */
|
|
body: string;
|
|
/** A delete or a move, which the dialog styles differently. */
|
|
destructive: boolean;
|
|
}
|
|
|
|
const VERBS: Record<FileOperation, string> = {
|
|
create: "Create",
|
|
overwrite: "Overwrite",
|
|
edit: "Edit",
|
|
delete: "Delete",
|
|
move: "Move",
|
|
};
|
|
|
|
/**
|
|
* Turns a request into the words the user reads.
|
|
*
|
|
* The path is the second thing in the body and never omitted: it is the thing being
|
|
* approved, and a dialog that says "Lam wants to write a file" would leave the user
|
|
* with nothing to judge.
|
|
*/
|
|
export function describeApproval(request: ApprovalRequest): FileApproval {
|
|
return {
|
|
title: `${VERBS[request.operation]} ${request.subject}?`,
|
|
body: [request.display, ...request.details].join("\n"),
|
|
destructive: request.irreversible,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The ledger: one approval per resolved path, per answer (A1), except for the
|
|
* irreversible operations, which ask every time (A2).
|
|
*
|
|
* Per answer rather than per session because a session can run for hours, and
|
|
* because "yes, edit that file while we work on it" is not consent to whatever lam
|
|
* decides to do with it in twenty minutes.
|
|
*/
|
|
export function createApprovalLedger(confirm: (approval: FileApproval) => Promise<boolean>): {
|
|
approve: ApproveFn;
|
|
} {
|
|
const approved = new Set<string>();
|
|
|
|
return {
|
|
async approve(request: ApprovalRequest): Promise<boolean> {
|
|
if (!request.irreversible && approved.has(request.path)) return true;
|
|
|
|
// A dialog that cannot be shown is not consent, and neither is an exception
|
|
// thrown while trying to show one, so both come back as a refusal.
|
|
const granted = await confirm(describeApproval(request)).catch(() => false);
|
|
if (granted && !request.irreversible) approved.add(request.path);
|
|
|
|
return granted;
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Write the Vicinae-side confirmation**
|
|
|
|
Append to `src/lib/approval.ts`:
|
|
|
|
```ts
|
|
/**
|
|
* The dialog itself.
|
|
*
|
|
* Vicinae's `rememberUserChoice` is declared in the API but not implemented in the
|
|
* server, so nothing here offers "do not ask again": the ledger above is the only
|
|
* memory, and it lasts one answer.
|
|
*/
|
|
export function confirmWithAlert(approval: FileApproval): Promise<boolean> {
|
|
return confirmAlert({
|
|
title: approval.title,
|
|
message: approval.body,
|
|
primaryAction: {
|
|
title: "Allow",
|
|
style: approval.destructive ? Alert.ActionStyle.Destructive : Alert.ActionStyle.Default,
|
|
},
|
|
dismissAction: { title: "Decline", style: Alert.ActionStyle.Cancel },
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/approval.test.ts`
|
|
Expected: PASS, 10 tests.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/lib/approval.ts src/__tests__/approval.test.ts
|
|
git commit -m "feat: approval wording and the per-answer ledger"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: The seven tools
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/tools.ts`
|
|
- Modify: `src/lib/agent.ts:236-243`
|
|
- Modify: `src/__tests__/tools.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/tools.test.ts`:
|
|
|
|
```ts
|
|
describe("the file tools", () => {
|
|
let home: string;
|
|
let files: { policy: PathPolicy; approve: ApproveFn };
|
|
|
|
beforeAll(async () => {
|
|
home = await mkdtemp(path.join(tmpdir(), "lam-tools-"));
|
|
await mkdir(path.join(home, "Downloads"), { recursive: true });
|
|
await writeFile(path.join(home, "Downloads", "notes.md"), "alpha\nbeta\n");
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(home, { recursive: true, force: true });
|
|
});
|
|
|
|
const context = (approve: ApproveFn = async () => true): ToolContext => ({
|
|
host: "h",
|
|
apiKey: "k",
|
|
files: { policy: createPolicy({ home }), approve },
|
|
});
|
|
|
|
it("is absent from the list unless file access is on", () => {
|
|
expect(toolsFor({ webEnabled: true }).some((tool) => tool.function.name === "read_file")).toBe(false);
|
|
expect(
|
|
toolsFor({ webEnabled: true, fileEnabled: true }).some((tool) => tool.function.name === "read_file"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("reads a file through the policy", async () => {
|
|
const result = await executeTool(
|
|
"read_file",
|
|
{ path: path.join(home, "Downloads", "notes.md") },
|
|
context(),
|
|
);
|
|
expect(result.isError).toBe(false);
|
|
expect(result.content).toContain("alpha");
|
|
});
|
|
|
|
it("returns a policy refusal as an error the model can read", async () => {
|
|
const result = await executeTool("read_file", { path: "/etc/passwd" }, context());
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toContain("outside the paths");
|
|
});
|
|
|
|
it("returns a declined write as an error, without throwing", async () => {
|
|
const result = await executeTool(
|
|
"write_file",
|
|
{ path: path.join(home, "Downloads", "new.md"), content: "x\n" },
|
|
context(async () => false),
|
|
);
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toContain("declined");
|
|
});
|
|
|
|
it("says a tool is unavailable when the context carries no policy", async () => {
|
|
const result = await executeTool("read_file", { path: "~" }, { host: "h", apiKey: "k" });
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toContain("not available");
|
|
});
|
|
|
|
it("requires its arguments", async () => {
|
|
const result = await executeTool("read_file", {}, context());
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toContain("path");
|
|
});
|
|
});
|
|
```
|
|
|
|
Add `createPolicy`, `type ApproveFn`, `type PathPolicy` to the imports alongside `ToolContext`.
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/tools.test.ts`
|
|
Expected: FAIL, `Unknown tool "read_file"` (the first new test fails on gating).
|
|
|
|
- [ ] **Step 3: Add the context field and the definitions**
|
|
|
|
In `src/lib/tools.ts`, add to the imports:
|
|
|
|
```ts
|
|
import {
|
|
deletePath,
|
|
editTextFile,
|
|
listDirectory,
|
|
movePath,
|
|
readTextFile,
|
|
searchFiles,
|
|
writeTextFile,
|
|
type ApproveFn,
|
|
type FileResult,
|
|
type PathPolicy,
|
|
} from "./files";
|
|
```
|
|
|
|
Add to `ToolContext`, after `sources`:
|
|
|
|
```ts
|
|
/**
|
|
* The file tools' policy and approval step, when file access is switched on.
|
|
*
|
|
* Both arrive together because neither is usable alone: the policy decides what
|
|
* may be touched and the approval decides whether it happens. Absent means the
|
|
* tools are not offered at all, and a call that arrives anyway is refused.
|
|
*/
|
|
files?: { policy: PathPolicy; approve: ApproveFn };
|
|
```
|
|
|
|
Add to `TOOL_DEFINITIONS`, after the `web_fetch` entry:
|
|
|
|
```ts
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "list_directory",
|
|
description:
|
|
"List what is in a folder on the user's machine: names, which entries are directories, their sizes and how long ago they changed. Use it before reading a file to get the exact name, and to see what a folder holds.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description:
|
|
"Absolute path, or one starting with ~. A relative path is read as relative to the user's home directory.",
|
|
},
|
|
hidden: { type: "boolean", description: "Include dotfiles. Defaults to false." },
|
|
},
|
|
required: ["path"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "search_files",
|
|
description:
|
|
"Find files by name under a folder, optionally only those whose contents contain a string. Use it when you do not know where something is. Credential files are never returned.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "Folder to search, absolute or starting with ~." },
|
|
pattern: {
|
|
type: "string",
|
|
description: 'Glob for the file name, such as "*.md" or "report*". Use "*" for everything.',
|
|
},
|
|
contains: {
|
|
type: "string",
|
|
description: "Only files whose contents contain this text, case-insensitively.",
|
|
},
|
|
},
|
|
required: ["path", "pattern"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "read_file",
|
|
description:
|
|
"Read a text file. Lines come back numbered, and a long file comes back in parts: use offset and limit to continue from where the last read stopped rather than asking for the whole thing. Binary files are refused.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "File to read, absolute or starting with ~." },
|
|
offset: { type: "integer", description: "First line to return, counting from 1." },
|
|
limit: { type: "integer", description: "How many lines to return. Defaults to 200." },
|
|
},
|
|
required: ["path"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "write_file",
|
|
description:
|
|
"Create a file, or replace one entirely, with the content you supply. The user approves every change in a dialog before it happens, and a refusal is final: do not retry it, say what you could not do instead. Prefer edit_file for a small change to an existing file.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "File to write, absolute or starting with ~." },
|
|
content: { type: "string", description: "The complete new contents of the file." },
|
|
},
|
|
required: ["path", "content"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "edit_file",
|
|
description:
|
|
"Replace an exact string in an existing file, leaving the rest of it alone. old_string must appear exactly once, or pass all to replace every occurrence. Include enough surrounding text to make it unique, and never guess at the contents: read the file first.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "File to edit, absolute or starting with ~." },
|
|
old_string: { type: "string", description: "The exact text to replace, copied from the file." },
|
|
new_string: { type: "string", description: "The text to put in its place." },
|
|
all: { type: "boolean", description: "Replace every occurrence rather than requiring one." },
|
|
},
|
|
required: ["path", "old_string", "new_string"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "delete_path",
|
|
description:
|
|
"Delete one file, or one empty folder. A folder with anything in it is refused, because Lam never deletes recursively. The user approves every deletion and it cannot be undone.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "File or empty folder to delete." },
|
|
},
|
|
required: ["path"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "move_path",
|
|
description:
|
|
"Move or rename a file or folder. Both ends must be somewhere Lam may change, the destination must not already exist, and the user approves every move.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
from: { type: "string", description: "Current path." },
|
|
to: { type: "string", description: "New path, which must not exist yet." },
|
|
},
|
|
required: ["from", "to"],
|
|
},
|
|
},
|
|
},
|
|
```
|
|
|
|
Add after `WEB_TOOL_NAMES`:
|
|
|
|
```ts
|
|
/** Tools that read or change files, offered only when the user has switched file access on. */
|
|
export const FILE_TOOL_NAMES = new Set([
|
|
"list_directory",
|
|
"search_files",
|
|
"read_file",
|
|
"write_file",
|
|
"edit_file",
|
|
"delete_path",
|
|
"move_path",
|
|
]);
|
|
```
|
|
|
|
Add to `TOOL_LABELS`:
|
|
|
|
```ts
|
|
list_directory: "Listing a folder",
|
|
search_files: "Searching your files",
|
|
read_file: "Reading a file",
|
|
write_file: "Writing a file",
|
|
edit_file: "Editing a file",
|
|
delete_path: "Deleting a file",
|
|
move_path: "Moving a file",
|
|
```
|
|
|
|
Replace `toolsFor` with:
|
|
|
|
```ts
|
|
/** Filters out the tools the user has switched off. */
|
|
export function toolsFor(options: { webEnabled: boolean; fileEnabled?: boolean }): ToolDefinition[] {
|
|
return TOOL_DEFINITIONS.filter((tool) => {
|
|
const name = tool.function.name;
|
|
if (WEB_TOOL_NAMES.has(name)) return options.webEnabled;
|
|
if (FILE_TOOL_NAMES.has(name)) return options.fileEnabled ?? false;
|
|
return true;
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add the handlers**
|
|
|
|
In `executeTool`, add the cases before `default`:
|
|
|
|
```ts
|
|
case "list_directory":
|
|
return await runFileTool(context, (files) =>
|
|
listDirectory(requirePath(args), files.policy, { hidden: args.hidden === true }),
|
|
);
|
|
|
|
case "search_files":
|
|
return await runFileTool(context, (files) => {
|
|
const pattern = asString(args.pattern);
|
|
if (!pattern) return Promise.resolve(missing("search_files", "pattern"));
|
|
return searchFiles(
|
|
{ path: requirePath(args), pattern, contains: asString(args.contains) },
|
|
files.policy,
|
|
);
|
|
});
|
|
|
|
case "read_file":
|
|
return await runFileTool(context, (files) =>
|
|
readTextFile(
|
|
{
|
|
path: requirePath(args),
|
|
offset: asCount(args.offset, 1, Number.MAX_SAFE_INTEGER),
|
|
limit: asCount(args.limit, 1, 5000),
|
|
},
|
|
files.policy,
|
|
),
|
|
);
|
|
|
|
case "write_file":
|
|
return await runFileTool(context, (files) =>
|
|
writeTextFile(
|
|
{ path: requirePath(args), content: typeof args.content === "string" ? args.content : "" },
|
|
files.policy,
|
|
files.approve,
|
|
),
|
|
);
|
|
|
|
case "edit_file":
|
|
return await runFileTool(context, (files) => {
|
|
const oldString = typeof args.old_string === "string" ? args.old_string : undefined;
|
|
const newString = typeof args.new_string === "string" ? args.new_string : "";
|
|
if (oldString === undefined) return Promise.resolve(missing("edit_file", "old_string"));
|
|
return editTextFile(
|
|
{ path: requirePath(args), oldString, newString, all: args.all === true },
|
|
files.policy,
|
|
files.approve,
|
|
);
|
|
});
|
|
|
|
case "delete_path":
|
|
return await runFileTool(context, (files) =>
|
|
deletePath(requirePath(args), files.policy, files.approve),
|
|
);
|
|
|
|
case "move_path":
|
|
return await runFileTool(context, (files) => {
|
|
const to = asString(args.to);
|
|
if (!to) return Promise.resolve(missing("move_path", "to"));
|
|
return movePath({ from: requirePath(args), to }, files.policy, files.approve);
|
|
});
|
|
```
|
|
|
|
Add the helpers next to `asString`:
|
|
|
|
```ts
|
|
/** The path argument every file tool needs, as written. */
|
|
function requirePath(args: Record<string, unknown>): string {
|
|
return typeof args.path === "string" ? args.path : "";
|
|
}
|
|
|
|
function missing(tool: string, argument: string): ToolResult {
|
|
return { content: `${tool} requires a "${argument}".`, isError: true };
|
|
}
|
|
|
|
/**
|
|
* Runs one file operation, once the context is known to have a policy.
|
|
*
|
|
* The missing-policy branch is not dead code: `toolsFor` keeps these tools out of
|
|
* the list when file access is off, so a call only arrives here if a model invents
|
|
* one, and then the honest answer is that the tool is not available.
|
|
*/
|
|
async function runFileTool(
|
|
context: ToolContext,
|
|
run: (files: { policy: PathPolicy; approve: ApproveFn }) => Promise<FileResult>,
|
|
): Promise<ToolResult> {
|
|
const files = context.files;
|
|
if (!files) {
|
|
return {
|
|
content: "File tools are not available in this session. The user has not switched file access on.",
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const result = await run(files);
|
|
return result.ok ? { content: result.content, isError: false } : { content: result.content, isError: true };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Pass the context through the agent**
|
|
|
|
In `src/lib/agent.ts`, add one field to the `executeTool` call:
|
|
|
|
```ts
|
|
files: options.toolContext.files,
|
|
```
|
|
|
|
- [ ] **Step 6: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/tools.test.ts src/__tests__/agent.test.ts`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/lib/tools.ts src/lib/agent.ts src/__tests__/tools.test.ts
|
|
git commit -m "feat: seven file tools behind the policy and an approval"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: The three preferences
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/preferences.ts`
|
|
- Modify: `package.json`
|
|
- Modify: `src/__tests__/preferences.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/preferences.test.ts`:
|
|
|
|
```ts
|
|
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.
|
|
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([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/preferences.test.ts`
|
|
Expected: FAIL, `fileEnabled` is undefined.
|
|
|
|
- [ ] **Step 3: Add the fields**
|
|
|
|
In `src/lib/preferences.ts`, add to `LamPreferences`:
|
|
|
|
```ts
|
|
fileAccess?: boolean;
|
|
readRoots?: string;
|
|
writeRoots?: string;
|
|
```
|
|
|
|
Add to `ResolvedPreferences`, after `webEnabled`:
|
|
|
|
```ts
|
|
/**
|
|
* Whether the file tools are offered at all.
|
|
*
|
|
* Off by default, and off means the model never learns the tools exist. The read
|
|
* side is the one that cannot show a dialog before it happens, so it is the side
|
|
* that decides this default.
|
|
*/
|
|
fileEnabled: boolean;
|
|
/** Extra read roots from the preference, already absolute. */
|
|
extraReadRoots: string[];
|
|
/** Extra write roots from the preference, already absolute. */
|
|
extraWriteRoots: string[];
|
|
/** Entries from either field that were not absolute paths, for the setup screen to report. */
|
|
rejectedReadRoots: string[];
|
|
rejectedWriteRoots: string[];
|
|
```
|
|
|
|
Add to the object `resolvePreferences` returns, after `webEnabled`:
|
|
|
|
```ts
|
|
fileEnabled: raw.fileAccess ?? false,
|
|
extraReadRoots: splitRoots(raw.readRoots).roots,
|
|
extraWriteRoots: splitRoots(raw.writeRoots).roots,
|
|
rejectedReadRoots: splitRoots(raw.readRoots).rejected,
|
|
rejectedWriteRoots: splitRoots(raw.writeRoots).rejected,
|
|
```
|
|
|
|
and to the imports:
|
|
|
|
```ts
|
|
import { splitRoots } from "./files";
|
|
```
|
|
|
|
- [ ] **Step 4: Declare them in the manifest**
|
|
|
|
In `package.json`, add three entries to the end of the `preferences` array (before the closing bracket), matching the surrounding shape:
|
|
|
|
```json
|
|
{
|
|
"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. Everything here is still approved change by change.",
|
|
"type": "textfield",
|
|
"required": false
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/preferences.test.ts`
|
|
Expected: PASS, and the existing 20 preferences tests still pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/lib/preferences.ts package.json src/__tests__/preferences.test.ts
|
|
git commit -m "feat: file access preferences"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: What the model is told
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/history.ts`
|
|
- Modify: `src/__tests__/history.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/history.test.ts`:
|
|
|
|
```ts
|
|
describe("the runtime paragraph and file access", () => {
|
|
const context = (overrides: Partial<RuntimeContext> = {}): RuntimeContext => ({
|
|
model: "gpt-oss:120b",
|
|
host: "https://ollama.com",
|
|
selfHosted: false,
|
|
webTools: true,
|
|
vision: "yes",
|
|
...overrides,
|
|
});
|
|
|
|
it("still says Lam cannot read files when file access is off", () => {
|
|
expect(describeRuntime(context())).toContain("You cannot read their screen, files");
|
|
});
|
|
|
|
it("names the tools, the roots and the approval rule when it is on", () => {
|
|
const paragraph = describeRuntime(
|
|
context({
|
|
fileTools: true,
|
|
fileRoots: { read: ["~", "/Volumes/Archive"], write: ["~/Downloads", "~/Documents/Lam Agent"] },
|
|
}),
|
|
);
|
|
|
|
expect(paragraph).toContain("read_file");
|
|
expect(paragraph).toContain("/Volumes/Archive");
|
|
expect(paragraph).toContain("~/Documents/Lam Agent");
|
|
expect(paragraph).toContain("approved");
|
|
// The one thing the dialog cannot tell the user on the model's behalf.
|
|
expect(paragraph).toContain("sent to the model provider");
|
|
expect(paragraph).not.toContain("You cannot read their screen, files");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/history.test.ts`
|
|
Expected: FAIL on the second test, `read_file` not found.
|
|
|
|
- [ ] **Step 3: Extend the runtime description**
|
|
|
|
In `src/lib/history.ts`, add the import:
|
|
|
|
```ts
|
|
import { FILE_TOOL_GUIDANCE } from "./constants";
|
|
```
|
|
|
|
Add to `RuntimeContext`:
|
|
|
|
```ts
|
|
/**
|
|
* Whether the file tools are available this session.
|
|
*
|
|
* Optional, so a caller that predates file access, and every test literal, keeps
|
|
* describing a session where Lam cannot read files, which is what it could not.
|
|
*/
|
|
fileTools?: boolean;
|
|
/** The roots, tilde shortened, for the paragraph that names them. */
|
|
fileRoots?: { read: string[]; write: string[] };
|
|
```
|
|
|
|
In `describeRuntime`, replace the line that begins `"- You see only what the user types or attaches.` with:
|
|
|
|
```ts
|
|
context.fileTools
|
|
? "- You see what the user types, what they attach, and what you look at with your file tools. You cannot read their screen, clipboard or calendar, and nothing on the machine changes except through a file change the user approved."
|
|
: "- You see only what the user types or attaches. You cannot read their screen, files, clipboard, calendar or anything else on the machine, and you cannot act on it.",
|
|
```
|
|
|
|
and after the `context.webTools` block, add:
|
|
|
|
```ts
|
|
if (context.fileTools) {
|
|
const read = context.fileRoots?.read ?? ["~"];
|
|
const write = context.fileRoots?.write ?? [];
|
|
|
|
lines.push(
|
|
"- You have file tools: `list_directory`, `search_files`, `read_file`, `write_file`, `edit_file`, `delete_path` and `move_path`.",
|
|
`- You may read ${andList(read)} and change ${andList(write)}. Paths outside those are refused outright, and credential stores such as ~/.ssh are never readable.`,
|
|
`- ${FILE_TOOL_GUIDANCE}`,
|
|
);
|
|
}
|
|
```
|
|
|
|
and add this helper at the bottom of the file, next to the other formatting helpers:
|
|
|
|
```ts
|
|
/** "a, b and c", so a list of roots reads as a sentence. */
|
|
function andList(items: string[]): string {
|
|
if (items.length === 0) return "nothing";
|
|
if (items.length === 1) return items[0];
|
|
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/history.test.ts`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/history.ts src/__tests__/history.test.ts
|
|
git commit -m "feat: tell the model what its file tools reach"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Wiring the hook and the setup screen
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/hooks/useChat.ts`
|
|
- Modify: `src/setup.tsx`
|
|
|
|
- [ ] **Step 1: Build the policy and the ledger in the hook**
|
|
|
|
In `src/lib/hooks/useChat.ts`, add to the imports:
|
|
|
|
```ts
|
|
import { environment } from "@vicinae/api";
|
|
import { confirmWithAlert, createApprovalLedger } from "../approval";
|
|
import { createPolicy, displayPath } from "../files";
|
|
```
|
|
|
|
Replace the `const messages = buildMessages(` opening with:
|
|
|
|
```ts
|
|
// Built per turn rather than per session: the ledger inside it is the record of
|
|
// what the user approved while answering this one question, and a new question
|
|
// starts by asking again (A1).
|
|
const policy = createPolicy({
|
|
supportPath: environment.supportPath,
|
|
readRoots: preferences.extraReadRoots,
|
|
writeRoots: preferences.extraWriteRoots,
|
|
});
|
|
const fileAccess = preferences.fileEnabled
|
|
? { policy, approve: createApprovalLedger(confirmWithAlert).approve }
|
|
: undefined;
|
|
|
|
const messages = buildMessages(
|
|
composeSystemPrompt(preferences.systemPrompt, {
|
|
model: preferences.model,
|
|
host: preferences.host,
|
|
selfHosted: isSelfHosted(preferences.host),
|
|
webTools: preferences.webEnabled,
|
|
vision: visionRef.current,
|
|
fileTools: preferences.fileEnabled,
|
|
fileRoots: {
|
|
read: policy.readRoots.map((root) => displayPath(root, policy.home)),
|
|
write: policy.writeRoots.map((root) => displayPath(root, policy.home)),
|
|
},
|
|
}),
|
|
```
|
|
|
|
Change the `tools:` line to:
|
|
|
|
```ts
|
|
tools: toolsFor({
|
|
webEnabled: preferences.webEnabled,
|
|
fileEnabled: preferences.fileEnabled,
|
|
}),
|
|
```
|
|
|
|
and add to the `toolContext`:
|
|
|
|
```ts
|
|
files: fileAccess,
|
|
```
|
|
|
|
- [ ] **Step 2: Show it on the setup screen**
|
|
|
|
In `src/setup.tsx`, add after the Web Tools item:
|
|
|
|
```tsx
|
|
<List.Item
|
|
icon={Icon.Folder}
|
|
title="File Access"
|
|
subtitle={preferences.fileEnabled ? "Enabled" : "Disabled"}
|
|
actions={settingsActions}
|
|
/>
|
|
```
|
|
|
|
Add `Icon.Folder` to the existing icon import if it is imported by name.
|
|
|
|
- [ ] **Step 3: Verify it typechecks and the suite is green**
|
|
|
|
Run: `npx tsc --noEmit && npx eslint . && npx vitest run`
|
|
Expected: no output from the first two, and every test passing.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/lib/hooks/useChat.ts src/setup.tsx
|
|
git commit -m "feat: wire file access into the chat hook and the setup screen"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: The README
|
|
|
|
**Files:**
|
|
|
|
- Modify: `README.md`
|
|
|
|
- [ ] **Step 1: Replace the promise the feature breaks**
|
|
|
|
The top of `README.md` describes Lam as chat only and states that it "does not touch your files or run commands". Replace that sentence with what is now true: Lam reads and changes files only when the File Access preference is on, only inside the configured roots, only with approval for every change, and it still runs no commands. Keep the surrounding sentences about attachments and tools, and keep the tone of the file, which is plain and short.
|
|
|
|
- [ ] **Step 2: Add a Files section**
|
|
|
|
Add a `### Files` subsection next to the existing tool sections, covering:
|
|
|
|
- The read roots: `~`, minus credential stores, plus any Extra Readable Folders.
|
|
- The write roots: `~/Downloads`, `~/Documents/Lam Agent`, the extension's own support directory, plus any Extra Writable Folders.
|
|
- That every change is confirmed in a dialog naming the exact path, that a delete or a move asks every time even inside an approved path, and that the second write to the same path in one answer does not ask again.
|
|
- That delete refuses a folder with anything in it, and that a move never lands on an existing file.
|
|
- That reads are sent to the model provider, which is the same disclosure the web tools make, and is the reason the credential denylist exists.
|
|
- The caps: 200 lines and 8 KB per read, 200 entries per listing, 50 results per search, 5 MB per file.
|
|
|
|
- [ ] **Step 3: Add the preferences**
|
|
|
|
Add rows to the preferences table for `File Access`, `Extra Readable Folders` and `Extra Writable Folders`, matching the existing row style exactly.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add README.md
|
|
git commit -m "docs: what file access reaches, and what it costs"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Whole-feature verification
|
|
|
|
**Files:** none, this task only runs things.
|
|
|
|
- [ ] **Step 1: The full gate**
|
|
|
|
```bash
|
|
npx tsc --noEmit && npx eslint . && npx prettier --check src/lib/files.ts src/lib/approval.ts src/lib/tools.ts src/lib/preferences.ts src/lib/history.ts src/lib/hooks/useChat.ts src/__tests__/files.test.ts src/__tests__/approval.test.ts && npx vitest run
|
|
```
|
|
|
|
Expected: typecheck clean, lint clean, prettier clean, every test passing.
|
|
|
|
- [ ] **Step 2: Mutation check the policy**
|
|
|
|
Prove the tests are load-bearing, one mutation at a time, restoring each. Each one must
|
|
fail at least one test, and the point is to prove the tests can fail, not that the
|
|
code works:
|
|
|
|
```bash
|
|
cp src/lib/files.ts /tmp/files.bak
|
|
|
|
# 1. Check containment before realpath: the symlink escape test must fail.
|
|
perl -0pi -e 's/const resolved = await resolvePath\(written, policy\);\n if \(!resolved\.ok\)/const unverified = await resolvePath(written, policy);\n if (!unverified.ok)/s' src/lib/files.ts
|
|
|
|
# 2. Trust a plain string prefix instead of a component comparison: the
|
|
# Downloads-evil test must fail.
|
|
perl -0pi -e 's/ const prefix = root\.endsWith\(path\.sep\) \? root : `\$\{root\}\$\{path\.sep\}`;\n return target\.startsWith\(prefix\);/ return target.startsWith(root);/s' src/lib/files.ts
|
|
npx vitest run src/__tests__/files.test.ts
|
|
cp /tmp/files.bak src/lib/files.ts
|
|
|
|
# 3. Key the ledger on the written path instead of the resolved one: the two
|
|
# spellings test must fail.
|
|
cp src/lib/approval.ts /tmp/approval.bak
|
|
perl -0pi -e 's/approved\.has\(request\.path\)/approved.has(request.display)/; s/approved\.add\(request\.path\)/approved.add(request.display)/' src/lib/approval.ts
|
|
|
|
# 4. Let a delete use the ledger: the asks-every-time test must fail.
|
|
perl -0pi -e 's/if \(!request\.irreversible && approved\.has\(request\.display\)\)/if (approved.has(request.display))/' src/lib/approval.ts
|
|
npx vitest run src/__tests__/approval.test.ts
|
|
cp /tmp/approval.bak src/lib/approval.ts
|
|
|
|
# Back to green, which is the state the commit needs.
|
|
npx vitest run src/__tests__/files.test.ts src/__tests__/approval.test.ts
|
|
```
|
|
|
|
- [ ] **Step 3: End to end through the real pipeline**
|
|
|
|
Write a scratch test that builds a policy over a temporary directory, calls `executeTool` for a read, a write and a delete with a recording approval callback, and prints each result with `renderModelOutput`, so the tool output the model would read and the refusal text the transcript would show are both seen rather than assumed. Delete the scratch file afterwards.
|
|
|
|
- [ ] **Step 4: Confirm the built extension carries the tools**
|
|
|
|
```bash
|
|
grep -c "read_file" ~/.local/share/vicinae/extensions/lam-agent/chat.js
|
|
grep -c "fileAccess" ~/.local/share/vicinae/extensions/lam-agent/package.json
|
|
```
|
|
|
|
Expected: both non-zero after the dev watcher has rebuilt. If the manifest has not been reinstalled, restart `vici develop`, because a preference change needs the extension reinstalled, not only rebuilt.
|
|
|
|
- [ ] **Step 5: Merge**
|
|
|
|
```bash
|
|
git push -u origin feat/file-system-access
|
|
tea pr create --title "feat: file system access" --head feat/file-system-access --base prod --description "$(cat /tmp/file-access-pr.md)"
|
|
tea pr merge <index> -s squash
|
|
git checkout prod && git pull && git push origin --delete feat/file-system-access
|
|
git branch -D feat/file-system-access
|
|
```
|