diff --git a/.changeset/fs-tools-allowlist.md b/.changeset/fs-tools-allowlist.md new file mode 100644 index 000000000..4f3473ba0 --- /dev/null +++ b/.changeset/fs-tools-allowlist.md @@ -0,0 +1,5 @@ +--- +"deepagents": minor +--- + +feat(filesystem): add allowlist for filesystem middleware tools diff --git a/libs/deepagents/src/agent.test.ts b/libs/deepagents/src/agent.test.ts index 816c0376b..3475404c2 100644 --- a/libs/deepagents/src/agent.test.ts +++ b/libs/deepagents/src/agent.test.ts @@ -308,6 +308,19 @@ describe("System prompt cache control breakpoints", () => { }); }); +describe("profile tool exclusions", () => { + it("removes excluded filesystem tools before agent construction", () => { + registerHarnessProfile("fstoolstest", { excludedTools: ["execute"] }); + + const agent = createDeepAgent({ model: "fstoolstest:model" }); + const tools = (agent as any).graph?.nodes?.tools?.bound?.tools ?? []; + const toolNames = tools.map((tool: { name: string }) => tool.name); + + expect(toolNames).toContain("read_file"); + expect(toolNames).not.toContain("execute"); + }); +}); + describe("Built-in tool name collision detection", () => { const model = new FakeListChatModel({ responses: ["Done"] }); diff --git a/libs/deepagents/src/agent.ts b/libs/deepagents/src/agent.ts index 981e071d1..c48abf86e 100644 --- a/libs/deepagents/src/agent.ts +++ b/libs/deepagents/src/agent.ts @@ -25,6 +25,7 @@ import { createSkillsMiddleware, FILESYSTEM_TOOL_NAMES, ASYNC_TASK_TOOL_NAMES, + type FsToolName, type SubAgent, createAsyncSubAgentMiddleware, isAsyncSubAgent, @@ -256,6 +257,15 @@ export function createDeepAgent< identifierHint: getModelIdentifier(model), }); + const filesystemTools = FILESYSTEM_TOOL_NAMES.filter( + (toolName) => !harnessProfile.excludedTools.has(toolName), + ); + const profileFilesystemTools: readonly FsToolName[] | undefined = + filesystemTools.length === FILESYSTEM_TOOL_NAMES.length || + !filesystemTools.includes("read_file") + ? undefined + : filesystemTools; + const toolOverrides = harnessProfile.toolDescriptionOverrides; const effectiveTools: StructuredTool[] = Object.keys(toolOverrides).length > 0 @@ -310,6 +320,7 @@ export function createDeepAgent< createFilesystemMiddleware({ backend, permissions: effectivePermissions, + tools: profileFilesystemTools, }), // Automatically summarizes conversation history when token limits are approached. // Uses createSummarizationMiddleware (deepagents version) with backend support @@ -387,7 +398,11 @@ export function createDeepAgent< // Provides todo list management capabilities for tracking tasks. todoListMiddleware(), // Enables filesystem operations and optional long-term memory storage. - createFilesystemMiddleware({ backend, permissions }), + createFilesystemMiddleware({ + backend, + permissions, + tools: profileFilesystemTools, + }), // Enables delegation to specialized subagents for complex tasks. createSubAgentMiddleware({ defaultModel: model, diff --git a/libs/deepagents/src/browser.ts b/libs/deepagents/src/browser.ts index c2caa26eb..daf4ed698 100644 --- a/libs/deepagents/src/browser.ts +++ b/libs/deepagents/src/browser.ts @@ -87,6 +87,7 @@ export { type CompletionCallbackOptions, // Other middleware types type FilesystemMiddlewareOptions, + type FsToolName, type SubAgentMiddlewareOptions, type MemoryMiddlewareOptions, type SubAgent, diff --git a/libs/deepagents/src/index.ts b/libs/deepagents/src/index.ts index c76da2479..01317b16e 100644 --- a/libs/deepagents/src/index.ts +++ b/libs/deepagents/src/index.ts @@ -92,6 +92,7 @@ export { type CompletionCallbackOptions, // Other middleware types type FilesystemMiddlewareOptions, + type FsToolName, type SubAgentMiddlewareOptions, type MemoryMiddlewareOptions, type SubAgent, diff --git a/libs/deepagents/src/middleware/fs.int.test.ts b/libs/deepagents/src/middleware/fs.int.test.ts index e8d289e55..53546c10f 100644 --- a/libs/deepagents/src/middleware/fs.int.test.ts +++ b/libs/deepagents/src/middleware/fs.int.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { createAgent } from "langchain"; +import { createAgent, createMiddleware } from "langchain"; +import { FakeListChatModel } from "@langchain/core/utils/testing"; import { HumanMessage, ToolMessage } from "@langchain/core/messages"; import { InMemoryStore } from "@langchain/langgraph-checkpoint"; import { MemorySaver } from "@langchain/langgraph"; @@ -24,6 +25,53 @@ import { } from "../testing/utils.js"; describe("Filesystem Middleware Integration Tests", () => { + it("should remove allowlisted-out tools from model request and system prompt", async () => { + const capturedToolNames: string[][] = []; + const capturedSystemPrompts: string[] = []; + const spyMiddleware = createMiddleware({ + name: "FilesystemAllowlistSpyMiddleware", + wrapModelCall(request, handler) { + capturedToolNames.push( + request.tools.flatMap((tool) => + typeof tool.name === "string" ? [tool.name] : [], + ), + ); + capturedSystemPrompts.push(request.systemMessage.text); + return handler(request); + }, + }); + + const agent = createAgent({ + model: new FakeListChatModel({ responses: ["done"] }), + middleware: [ + createFilesystemMiddleware({ tools: ["read_file", "ls"] }), + spyMiddleware, + ], + }); + + await agent.invoke({ messages: [new HumanMessage("hi")] }); + + expect(capturedToolNames.length).toBeGreaterThan(0); + expect(capturedToolNames[0]).toContain("read_file"); + expect(capturedToolNames[0]).toContain("ls"); + for (const disabled of [ + "write_file", + "edit_file", + "glob", + "grep", + "execute", + ]) { + expect(capturedToolNames[0]).not.toContain(disabled); + } + + expect(capturedSystemPrompts.length).toBeGreaterThan(0); + expect(capturedSystemPrompts[0]).toContain("`read_file`"); + expect(capturedSystemPrompts[0]).toContain("`ls`"); + for (const disabled of ["write_file", "edit_file", "glob", "grep"]) { + expect(capturedSystemPrompts[0]).not.toContain(`\`${disabled}\``); + } + }); + it.concurrent.each([ { useComposite: false, label: "StateBackend" }, { useComposite: true, label: "CompositeBackend" }, diff --git a/libs/deepagents/src/middleware/fs.permissions.test.ts b/libs/deepagents/src/middleware/fs.permissions.test.ts index c6540d66b..e12c72d7a 100644 --- a/libs/deepagents/src/middleware/fs.permissions.test.ts +++ b/libs/deepagents/src/middleware/fs.permissions.test.ts @@ -402,6 +402,16 @@ describe("fs tool permissions", () => { ); }); + it("does not throw when permissions are used with a sandbox backend and execute is disabled", () => { + expect(() => + createFilesystemMiddleware({ + backend: createSandboxBackend(), + permissions: [deny(["/secrets/**"])], + tools: ["read_file"], + }), + ).not.toThrow(); + }); + it("does not throw when permissions is empty with a sandbox backend", () => { expect(() => createFilesystemMiddleware({ backend: createSandboxBackend() }), diff --git a/libs/deepagents/src/middleware/fs.test.ts b/libs/deepagents/src/middleware/fs.test.ts index ff5429eee..d057af8ba 100644 --- a/libs/deepagents/src/middleware/fs.test.ts +++ b/libs/deepagents/src/middleware/fs.test.ts @@ -361,6 +361,65 @@ describe("createFilesystemMiddleware", () => { } as unknown as BackendProtocolV2; } + function middlewareToolNames( + middleware: ReturnType, + ): string[] { + return (middleware.tools ?? []).map((tool) => tool.name); + } + + describe("tools allowlist", () => { + it("should keep all filesystem tools by default", () => { + const middleware = createFilesystemMiddleware({ + backend: createMockSandboxBackend(), + }); + + expect(middlewareToolNames(middleware)).toEqual([ + "ls", + "read_file", + "write_file", + "edit_file", + "glob", + "grep", + "execute", + ]); + }); + + it("should keep all filesystem tools when tools is all", () => { + const middleware = createFilesystemMiddleware({ + backend: createMockSandboxBackend(), + tools: "all", + }); + + expect(middlewareToolNames(middleware)).toEqual([ + "ls", + "read_file", + "write_file", + "edit_file", + "glob", + "grep", + "execute", + ]); + }); + + it("should only register allowlisted filesystem tools", () => { + const middleware = createFilesystemMiddleware({ + backend: createMockBackend(), + tools: ["read_file", "ls"], + }); + + expect(middlewareToolNames(middleware)).toEqual(["ls", "read_file"]); + }); + + it("should reject an allowlist without read_file", () => { + expect(() => + createFilesystemMiddleware({ + backend: createMockBackend(), + tools: ["ls"], + }), + ).toThrow(/read_file must be included in tools/); + }); + }); + describe("wrapModelCall", () => { it("should add filesystem system prompt to model call", async () => { const middleware = createFilesystemMiddleware({ @@ -436,6 +495,83 @@ describe("createFilesystemMiddleware", () => { expect(toolNames).not.toContain("execute"); }); + it("should keep execute allowlisted but filter it when backend does not support execution", async () => { + const middleware = createFilesystemMiddleware({ + backend: createMockBackend(), + tools: ["read_file", "execute"], + }); + + const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); + const request = { + systemMessage: new SystemMessage("Base prompt"), + state: {}, + config: {}, + tools: middleware.tools || [], + }; + + await middleware.wrapModelCall!(request as any, mockHandler); + + const modifiedRequest = mockHandler.mock.calls[0][0]; + const toolNames = modifiedRequest.tools.map( + (tool: { name: string }) => tool.name, + ); + expect(toolNames).toEqual(["read_file"]); + expect(modifiedRequest.systemMessage.text).not.toContain("Execute Tool"); + }); + + it("should list only visible filesystem tools in the system prompt", async () => { + const middleware = createFilesystemMiddleware({ + backend: createMockBackend(), + tools: ["read_file", "ls"], + }); + + const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); + const request = { + systemMessage: new SystemMessage("Base prompt"), + state: {}, + config: {}, + tools: middleware.tools || [], + }; + + await middleware.wrapModelCall!(request as any, mockHandler); + + const modifiedRequest = mockHandler.mock.calls[0][0]; + const prompt = modifiedRequest.systemMessage.text; + expect(prompt).toContain("`ls`"); + expect(prompt).toContain("`read_file`"); + expect(prompt).not.toContain("`write_file`"); + expect(prompt).not.toContain("`edit_file`"); + expect(prompt).not.toContain("`glob`"); + expect(prompt).not.toContain("`grep`"); + }); + + it("should not filter user-provided non-filesystem tools", async () => { + const middleware = createFilesystemMiddleware({ + backend: createMockBackend(), + tools: ["read_file", "ls"], + }); + const customTool = { name: "search" }; + + const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); + const request = { + systemMessage: new SystemMessage("Base prompt"), + state: {}, + config: {}, + tools: [...(middleware.tools || []), customTool], + }; + + await middleware.wrapModelCall!(request as any, mockHandler); + + const modifiedRequest = mockHandler.mock.calls[0][0]; + const toolNames = modifiedRequest.tools.map( + (tool: { name: string }) => tool.name, + ); + expect(toolNames).toContain("search"); + expect(toolNames).toContain("read_file"); + expect(toolNames).toContain("ls"); + expect(toolNames).not.toContain("write_file"); + }); + it("should use custom system prompt when provided", async () => { const customPrompt = "Custom filesystem instructions"; const middleware = createFilesystemMiddleware({ diff --git a/libs/deepagents/src/middleware/fs.ts b/libs/deepagents/src/middleware/fs.ts index c58197c69..81ece4296 100644 --- a/libs/deepagents/src/middleware/fs.ts +++ b/libs/deepagents/src/middleware/fs.ts @@ -103,14 +103,22 @@ export const FILESYSTEM_TOOL_NAMES = [ "execute", ] as const; -export const TOOLS_EXCLUDED_FROM_EVICTION = [ - "ls", - "glob", - "grep", - "read_file", - "edit_file", - "write_file", -] as const; +/** + * Built-in filesystem tool names accepted by + * {@link createFilesystemMiddleware}'s `tools` allowlist. + */ +export type FsToolName = (typeof FILESYSTEM_TOOL_NAMES)[number]; + +function isFilesystemToolName(name: unknown): name is FsToolName { + return ( + typeof name === "string" && + (FILESYSTEM_TOOL_NAMES as readonly string[]).includes(name) + ); +} + +export const TOOLS_EXCLUDED_FROM_EVICTION = FILESYSTEM_TOOL_NAMES.filter( + (name) => name !== "execute", +); /** * Approximate number of characters per token for truncation calculations. @@ -472,24 +480,50 @@ function filterByPermissions( } // System prompts -const FILESYSTEM_SYSTEM_PROMPT = context` - ## Following Conventions +const FILESYSTEM_TOOL_DESCRIPTION_LINES = { + ls: "ls: list files in a directory (requires absolute path)", + read_file: "read_file: read a file from the filesystem", + write_file: "write_file: write to a file in the filesystem", + edit_file: "edit_file: edit a file in the filesystem", + glob: 'glob: find files matching a pattern (e.g., "**/*.py")', + grep: "grep: search for text within files", +} as const satisfies Record, string>; + +type FilesystemToolWithDescription = + keyof typeof FILESYSTEM_TOOL_DESCRIPTION_LINES; + +function hasFilesystemToolDescription( + name: FsToolName, +): name is FilesystemToolWithDescription { + return name in FILESYSTEM_TOOL_DESCRIPTION_LINES; +} + +function buildFilesystemSystemPrompt( + visibleTools: ReadonlySet, +): string { + const promptToolNames = FILESYSTEM_TOOL_NAMES.filter((name) => + visibleTools.has(name), + ); + const toolHeader = promptToolNames.map((name) => `\`${name}\``).join(", "); + const toolDescriptions = promptToolNames + .filter(hasFilesystemToolDescription) + .map((name) => `- ${FILESYSTEM_TOOL_DESCRIPTION_LINES[name]}`) + .join("\n"); - - Read files before editing — understand existing content before making changes - - Mimic existing style, naming conventions, and patterns + return context` + ## Following Conventions - ## Filesystem Tools \`ls\`, \`read_file\`, \`write_file\`, \`edit_file\`, \`glob\`, \`grep\` + - Read files before editing — understand existing content before making changes + - Mimic existing style, naming conventions, and patterns - You have access to a filesystem which you can interact with using these tools. - All file paths must start with a /. + ## Filesystem Tools ${toolHeader} - - ls: list files in a directory (requires absolute path) - - read_file: read a file from the filesystem - - write_file: write to a file in the filesystem - - edit_file: edit a file in the filesystem - - glob: find files matching a pattern (e.g., "**/*.py") - - grep: search for text within files -`; + You have access to a filesystem which you can interact with using these tools. + All file paths must start with a /. + + ${toolDescriptions} + `; +} export const LS_TOOL_DESCRIPTION = context` Lists all files in a directory. @@ -1137,10 +1171,46 @@ function createExecuteTool( export interface FilesystemMiddlewareOptions { /** Backend instance or factory (default: StateBackend) */ backend?: AnyBackendProtocol | BackendFactory; - /** Optional custom system prompt override */ + /** + * Optional filesystem-specific system prompt override. + * + * When omitted, the middleware generates a prompt that reflects the tools + * visible for the current model request. Supplying a custom prompt replaces + * that generated filesystem prompt entirely. + */ systemPrompt?: string | null; - /** Optional custom tool descriptions override */ - customToolDescriptions?: Record | null; + /** + * Optional descriptions for built-in filesystem tools. + * + * Keys correspond to {@link FsToolName}. Descriptions for tools that are not + * enabled by the `tools` allowlist are ignored because those tools are not + * exposed to the model. + */ + customToolDescriptions?: Partial> | null; + /** + * Allowlist of built-in filesystem tools to expose to the model. + * + * - `undefined`, `null`, and `"all"` preserve the default behavior: every + * filesystem tool is registered, subject to backend capability filtering. + * - Passing an array restricts the middleware to only those tool names. + * - `read_file` must be included in every explicit array because it is used + * by normal file-inspection flows and by large-result recovery guidance. + * - Backend capability checks still narrow the final visible tool set. For + * example, `execute` is removed when the resolved backend does not support + * command execution, even if it appears in this allowlist. + * - User-provided non-filesystem tools are not affected by this allowlist. + * + * The generated filesystem system prompt is based on the tools that remain + * visible after this allowlist and backend capability filtering are applied. + * + * @example Read/search-only filesystem access + * ```ts + * createFilesystemMiddleware({ + * tools: ["read_file", "ls", "glob", "grep"], + * }); + * ``` + */ + tools?: readonly FsToolName[] | "all" | null; /** Optional token limit before evicting a tool result to the filesystem (default: 20000 tokens, ~80KB) */ toolTokenLimitBeforeEvict?: number | null; /** Optional token limit before evicting a HumanMessage to the filesystem (default: 50000 tokens, ~200KB) */ @@ -1155,8 +1225,11 @@ export interface FilesystemMiddlewareOptions { * **Note on `execute`**: permissions are not enforced on `execute` because * shell commands can access any path regardless of path-based rules. Using * permissions with an execution-capable backend (one where `isSandboxBackend` - * returns `true`) throws a `ConfigurationError` unless the backend is a - * `CompositeBackend` and every permission path is scoped to a route prefix. + * returns `true`) throws a `ConfigurationError` unless either: + * + * - `execute` is disabled via `tools`, or + * - the backend is a `CompositeBackend` and every permission path is scoped to + * a route prefix. * * When omitted or empty, all filesystem operations are permitted. */ @@ -1167,6 +1240,23 @@ export interface FilesystemMiddlewareOptions { * Returns true only when backend exposes route prefixes (CompositeBackend) and * every permission path is scoped under one of them. */ +function normalizeFilesystemTools( + tools: readonly FsToolName[] | "all" | null | undefined, +): ReadonlySet | null { + if (tools == null || tools === "all") { + return null; + } + + const enabledTools = new Set(tools); + if (!enabledTools.has("read_file")) { + throw new Error( + "read_file must be included in tools; it is required by FilesystemMiddleware", + ); + } + + return enabledTools; +} + function allPathsScopedToRoutes( permissions: FilesystemPermission[], backend: AnyBackendProtocol, @@ -1190,7 +1280,31 @@ function allPathsScopedToRoutes( } /** - * Create filesystem middleware with all tools and features. + * Create middleware that provides built-in filesystem tools and filesystem-aware + * prompt guidance. + * + * By default, the middleware registers every built-in filesystem tool listed in + * {@link FILESYSTEM_TOOL_NAMES}. Use {@link FilesystemMiddlewareOptions.tools} + * to narrow that set for read-only, search-only, or otherwise restricted + * agents. The allowlist only controls built-in filesystem tools; custom tools + * from the agent or other middleware are left untouched. + * + * The middleware also filters tools whose backend capabilities are unavailable + * at request time. In particular, `execute` is only visible when the resolved + * backend supports command execution. The filesystem prompt is generated from + * the final visible filesystem tools so the model is not instructed to call + * tools it cannot see. + * + * @param options Filesystem middleware configuration. + * @returns Agent middleware that contributes filesystem state, tools, prompt + * guidance, permission checks, and large-result eviction. + * + * @example Read-only filesystem middleware + * ```ts + * const middleware = createFilesystemMiddleware({ + * tools: ["read_file", "ls", "glob", "grep"], + * }); + * ``` */ export function createFilesystemMiddleware( options: FilesystemMiddlewareOptions = {}, @@ -1202,7 +1316,11 @@ export function createFilesystemMiddleware( toolTokenLimitBeforeEvict = 20000, humanMessageTokenLimitBeforeEvict = 50000, permissions = [], + tools: filesystemTools = null, } = options; + const enabledFilesystemTools = normalizeFilesystemTools(filesystemTools); + const executeToolEnabled = + enabledFilesystemTools == null || enabledFilesystemTools.has("execute"); if (permissions.length > 0) { validatePermissionPaths(permissions); @@ -1210,6 +1328,7 @@ export function createFilesystemMiddleware( if ( permissions.length > 0 && + executeToolEnabled && typeof backend !== "function" && isSandboxBackend(backend) && !allPathsScopedToRoutes(permissions, backend) @@ -1223,13 +1342,12 @@ export function createFilesystemMiddleware( ); } - const baseSystemPrompt = customSystemPrompt || FILESYSTEM_SYSTEM_PROMPT; + const baseSystemPrompt = customSystemPrompt ?? null; /** * All tools including execute * (execute will be filtered at runtime if backend doesn't support it) */ - type FilesystemToolName = (typeof FILESYSTEM_TOOL_NAMES)[number]; const allToolsByName = { ls: createLsTool(backend, { customDescription: customToolDescriptions?.ls, @@ -1260,8 +1378,11 @@ export function createFilesystemMiddleware( customDescription: customToolDescriptions?.execute, permissions, }), - } satisfies Record; - const allTools = Object.values(allToolsByName); + } satisfies Record; + const allTools = FILESYSTEM_TOOL_NAMES.filter( + (name) => + enabledFilesystemTools == null || enabledFilesystemTools.has(name), + ).map((name) => allToolsByName[name]); async function processToolMessage( msg: ToolMessage, @@ -1396,9 +1517,22 @@ export function createFilesystemMiddleware( tools = tools.filter((t: { name: string }) => t.name !== "execute"); } + const visibleFilesystemTools = new Set(); + for (const currentTool of tools) { + const toolName = + typeof currentTool.name === "string" ? currentTool.name : undefined; + if (isFilesystemToolName(toolName)) { + visibleFilesystemTools.add(toolName); + } + } + + const executionActive = + supportsExecution && visibleFilesystemTools.has("execute"); + // Build system prompt - add execution instructions if available - let filesystemPrompt = baseSystemPrompt; - if (supportsExecution) { + let filesystemPrompt = + baseSystemPrompt ?? buildFilesystemSystemPrompt(visibleFilesystemTools); + if (executionActive) { filesystemPrompt = `${filesystemPrompt}\n\n${EXECUTION_SYSTEM_PROMPT}`; } diff --git a/libs/deepagents/src/middleware/index.ts b/libs/deepagents/src/middleware/index.ts index 53d7ec76d..a1839756b 100644 --- a/libs/deepagents/src/middleware/index.ts +++ b/libs/deepagents/src/middleware/index.ts @@ -1,6 +1,7 @@ export { createFilesystemMiddleware, type FilesystemMiddlewareOptions, + type FsToolName, FILESYSTEM_TOOL_NAMES, // Eviction constants TOOLS_EXCLUDED_FROM_EVICTION,