From 674327ec208cadc55b2baa13d810b629f250d676 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 7 Oct 2026 16:58:43 +0800 Subject: [PATCH] feat: the file path policy and its operations One module with no Vicinae import, so the whole boundary tests headlessly: the roots, the credential denylist, resolution that follows symlinks before judging containment, and the seven operations, each returning a result rather than throwing, so a refusal and an I/O failure reach the model in the same shape. The mutating operations take an approval callback rather than importing a dialog, which is what keeps this file free of UI and lets every refusal path be tested. 52 tests, including a symlink out of a root, a sibling whose name merely starts like a root, and a temporary home with its own symlinks resolved first, because mkdtemp on macOS hands back a path under /var. --- src/__tests__/files.test.ts | 607 ++++++++++++++++++++++++++ src/lib/constants.ts | 28 ++ src/lib/files.ts | 821 ++++++++++++++++++++++++++++++++++++ 3 files changed, 1456 insertions(+) create mode 100644 src/__tests__/files.test.ts create mode 100644 src/lib/files.ts diff --git a/src/__tests__/files.test.ts b/src/__tests__/files.test.ts new file mode 100644 index 0000000..e9a2a7c --- /dev/null +++ b/src/__tests__/files.test.ts @@ -0,0 +1,607 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { + createPolicy, + deletePath, + denialFor, + editTextFile, + isWithin, + listDirectory, + movePath, + readDenial, + readTextFile, + resolvePath, + searchFiles, + splitRoots, + writeDenial, + writeTextFile, + type ApprovalRequest, + type PathPolicy, +} from "../lib/files"; + +/** + * A temporary home, with its symlinks resolved. + * + * `mkdtemp` on macOS hands back a path under `/var`, which is itself a symlink to + * `/private/var`, so a policy built on the raw value and a resolved path would + * disagree about every single path in these tests. Resolving here is what keeps the + * tests about the policy rather than about the platform. + */ +async function makeHome(prefix: string): Promise { + return realpath(await mkdtemp(path.join(tmpdir(), prefix))); +} + +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 makeHome("lam-files-"); + await mkdir(path.join(home, "Downloads", "reports"), { recursive: true }); + await writeFile(path.join(home, "Downloads", "report.md"), "hello\n"); + await mkdir(path.join(home, "outside"), { recursive: true }); + // 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")); + }); + + 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"); + }); +}); + +describe("listDirectory", () => { + let home: string; + let policy: PathPolicy; + + beforeEach(async () => { + home = await makeHome("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"); + }); + + afterEach(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; + + beforeEach(async () => { + home = await makeHome("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])); + }); + + afterEach(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("continue with offset 201"); + }); + + it("continues from an offset", async () => { + const result = await readTextFile({ path: path.join(home, "lines.txt"), offset: 201, 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; + + beforeEach(async () => { + home = await makeHome("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"); + }); + + afterEach(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"); + }); +}); + +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; + }; + + beforeEach(async () => { + home = await makeHome("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("shows the substitution in the approval", async () => { + await editTextFile( + { path: path.join(home, "Downloads", "report.md"), oldString: "two", newString: "TWO" }, + policy, + allow, + ); + + expect(asked[0].details.join(" ")).toContain("- two"); + expect(asked[0].details.join(" ")).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("does not contain"); + }); + + 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); + expect(asked[0].details.join(" ")).toContain("cannot be undone"); + 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); + }); +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c46a4b4..738548a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -107,3 +107,31 @@ Say what you are unsure about rather than hedging.`; /** Extra instruction appended when the user has web tools enabled and the question looks time-sensitive. */ export const RECENCY_HINT = "If the answer depends on current events, prices, versions, or releases, use web_search before answering."; + +/** + * 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 + * that the user cannot see from the dialog, which is that the contents leave the + * machine on their way to the model. + */ +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."; diff --git a/src/lib/files.ts b/src/lib/files.ts new file mode 100644 index 0000000..47fade8 --- /dev/null +++ b/src/lib/files.ts @@ -0,0 +1,821 @@ +import { mkdir, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import * as path from "node:path"; +import { + MAX_FILE_BYTES, + MAX_LIST_ENTRIES, + MAX_READ_CHARS, + MAX_READ_LINES, + MAX_SEARCH_BYTES, + MAX_SEARCH_DEPTH, + MAX_SEARCH_RESULTS, +} from "./constants"; + +/** + * Where Lam may read and write, and how a written path becomes a real one. + * + * No Vicinae import anywhere in this file, and no dialog: the policy is a value a + * test can build, and approval arrives as a callback from the caller. That split is + * what makes the whole boundary testable without a running launcher, and it is also + * why every function here returns a result rather than throwing: a refusal and an + * I/O failure reach the model in the same shape. + */ + +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 dot 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 ?? [])]), + // Downloads and a folder of Lam's own are the two the user named. The support + // directory comes third because it is where the extension is invited to write, + // which makes it the one place a failure cannot be blamed on a guessed path. + 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. Relative entries + * are rejected rather than resolved: in a tool argument a relative path has an obvious + * meaning (the user's home), and in a setting it has none. + */ +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; + + // The separator is the whole point: comparing raw prefixes would put + // /Users/me/Downloads-evil inside /Users/me/Downloads. + 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 { + 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 }; +} + +/** + * The real path of a location that may not exist yet. + * + * `realpath` fails on a path that is not there, and the create case is the one that + * matters most, so the nearest existing ancestor is resolved instead and the rest of + * the path is re-appended. That ancestor is also returned, because it is what the + * write check is judged against. + */ +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; +} + +/** "~ and /Volumes/Archive", for a refusal the model is meant to act on. */ +function describeRoots(roots: string[], home: string): string { + return roots.map((root) => displayPath(root, home)).join(", "); +} + +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; + +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. */ + 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 or written: ${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); +} + +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 { + 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 listDirectory( + written: string, + policy: PathPolicy, + options: { hidden?: boolean; now?: Date } = {}, +): Promise { + 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 { + 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 the model might try + // to read anyway. + 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 { + 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 => { + 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 become 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 written by a model or by the user. The + * escape step and the wildcards are one pass with a callback, because two passes need a + * placeholder for `**` and a placeholder in a regular expression is a control character + * this repository's lint forbids, having already been bitten by that once. + */ +export function globMatcher(pattern: string): (name: string) => boolean { + const escaped = pattern.replace(/[.+^${}()|[\]\\*?]/g, (character) => { + if (character === "*") return "[^/]*"; + if (character === "?") return "[^/]"; + return `\\${character}`; + }); + + const expression = new RegExp(`^${escaped}$`, "i"); + return (name: string) => expression.test(name); +} + +export async function writeTextFile( + args: { path: string; content: string }, + policy: PathPolicy, + approve: ApproveFn, + options: { now?: Date } = {}, +): Promise { + 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 bytes = Buffer.byteLength(content, "utf8"); + 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) { + details.push( + `Replaces ${formatBytes(existing.size)}, last changed ${describeAge(existing.mtime, options.now ?? new Date())}.`, + ); + } + details.push(`New content is ${formatBytes(bytes)}, ${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, + content: `${existing ? "Overwrote" : "Wrote"} ${display} (${formatBytes(bytes)}, ${lineCount(content)} lines).`, + }; +} + +export async function editTextFile( + args: { path: string; oldString: string; newString: string; all?: boolean }, + policy: PathPolicy, + approve: ApproveFn, +): Promise { + 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 { + 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, and no + // confirmation dialog makes that recoverable. + 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 { + 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}.`, + }; +}