feat: seven file tools behind the policy and an approval

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

move_path reads `from`, not the shared path helper, which is the bug the tests
caught: an empty source path would have been refused by the policy on every move.
This commit is contained in:
2026-10-07 17:00:08 +08:00
parent f8acfce792
commit f42a828360
3 changed files with 447 additions and 11 deletions
+157 -3
View File
@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { createSourceList } from "../lib/citations";
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor } from "../lib/tools";
import { createPolicy, type ApproveFn } from "../lib/files";
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor, type ToolContext } from "../lib/tools";
/**
* Direct coverage for the tool layer.
@@ -40,10 +44,12 @@ afterEach(() => {
});
describe("tool registration", () => {
it("exposes all four tools to the model", () => {
it("exposes the four always-available tools to the model", () => {
// The file tools are in the list too, but they are gated by a preference, so the
// unconditional ones are what this pins.
const names = TOOL_DEFINITIONS.map((tool) => tool.function.name);
expect(names).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]);
expect(names.slice(0, 4)).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]);
});
it("marks both web tools as cloud-dependent", () => {
@@ -469,3 +475,151 @@ describe("dispatch", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("the file tools", () => {
let home: string;
beforeEach(async () => {
// Resolved, because mkdtemp on macOS hands back a path under /var, which is a
// symlink: the policy would then be built on one path and check another.
home = await realpath(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");
});
afterEach(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("leaves the web tools alone when file access is off", () => {
const names = toolsFor({ webEnabled: true }).map((tool) => tool.function.name);
expect(names).toContain("web_search");
expect(names).not.toContain("delete_path");
});
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("writes through the approval, and reports a decline as an error", async () => {
const written = await executeTool(
"write_file",
{ path: path.join(home, "Downloads", "new.md"), content: "x\n" },
context(),
);
expect(written.isError).toBe(false);
const refused = await executeTool(
"write_file",
{ path: path.join(home, "Downloads", "other.md"), content: "x\n" },
context(async () => false),
);
expect(refused.isError).toBe(true);
expect(refused.content).toContain("declined");
});
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("lists a folder", async () => {
const result = await executeTool("list_directory", { path: home }, context());
expect(result.isError).toBe(false);
expect(result.content).toContain("Downloads/");
});
it("searches for a file by name", async () => {
const result = await executeTool("search_files", { path: home, pattern: "*.md" }, context());
expect(result.isError).toBe(false);
expect(result.content).toContain("notes.md");
});
it("edits a file", async () => {
const result = await executeTool(
"edit_file",
{
path: path.join(home, "Downloads", "notes.md"),
old_string: "alpha",
new_string: "ALPHA",
},
context(),
);
expect(result.isError).toBe(false);
expect(result.content).toContain("1 occurrence");
});
it("deletes and moves", async () => {
const moved = await executeTool(
"move_path",
{
from: path.join(home, "Downloads", "notes.md"),
to: path.join(home, "Downloads", "moved.md"),
},
context(),
);
expect(moved.isError).toBe(false);
const deleted = await executeTool(
"delete_path",
{ path: path.join(home, "Downloads", "moved.md") },
context(),
);
expect(deleted.isError).toBe(false);
expect(deleted.content).toContain("Deleted");
});
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 read = await executeTool("read_file", {}, context());
expect(read.isError).toBe(true);
expect(read.content).toContain("path");
const search = await executeTool("search_files", { path: home }, context());
expect(search.isError).toBe(true);
expect(search.content).toContain("pattern");
});
it("labels every file tool for the activity line", () => {
const tools = toolsFor({ webEnabled: true, fileEnabled: true });
for (const name of [
"list_directory",
"search_files",
"read_file",
"write_file",
"edit_file",
"delete_path",
"move_path",
]) {
expect(
tools.some((tool) => tool.function.name === name),
name,
).toBe(true);
}
});
});