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:
+157
-3
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user