import { Type } from '@sinclair/typebox'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; import { readFile, writeFile, readdir, stat } from 'node:fs/promises'; import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js'; const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit export function createFileTools(baseDir: string): ToolDefinition[] { const readFileTool: ToolDefinition = { name: 'fs_read_file', label: 'Read File', description: 'Read the contents of a file. Path is resolved relative to the sandbox base directory.', parameters: Type.Object({ path: Type.String({ description: 'File path (relative to sandbox base or absolute within it)', }), encoding: Type.Optional( Type.String({ description: 'Encoding: utf8 (default), base64, hex' }), ), }), async execute(_toolCallId, params) { const { path, encoding } = params as { path: string; encoding?: string }; let safePath: string; try { safePath = guardPath(path, baseDir); } catch (err) { if (err instanceof SandboxEscapeError) { return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], details: undefined, }; } return { content: [{ type: 'text' as const, text: `Error: ${String(err)}` }], details: undefined, }; } try { const info = await stat(safePath); if (!info.isFile()) { return { content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }], details: undefined, }; } if (info.size > MAX_READ_BYTES) { return { content: [ { type: 'text' as const, text: `Error: file too large (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`, }, ], details: undefined, }; } const enc = (encoding ?? 'utf8') as BufferEncoding; const content = await readFile(safePath, { encoding: enc }); return { content: [{ type: 'text' as const, text: String(content) }], details: undefined, }; } catch (err) { return { content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }], details: undefined, }; } }, }; const writeFileTool: ToolDefinition = { name: 'fs_write_file', label: 'Write File', description: 'Write content to a file. Path is resolved relative to the sandbox base directory. Overwrites existing file.', parameters: Type.Object({ path: Type.String({ description: 'File path (relative to sandbox base or absolute within it)', }), content: Type.String({ description: 'Content to write' }), encoding: Type.Optional(Type.String({ description: 'Encoding: utf8 (default), base64' })), }), async execute(_toolCallId, params) { const { path, content, encoding } = params as { path: string; content: string; encoding?: string; }; let safePath: string; try { safePath = guardPathUnsafe(path, baseDir); } catch (err) { if (err instanceof SandboxEscapeError) { return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], details: undefined, }; } return { content: [{ type: 'text' as const, text: `Error: ${String(err)}` }], details: undefined, }; } if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) { return { content: [ { type: 'text' as const, text: `Error: content too large (limit ${MAX_WRITE_BYTES} bytes)`, }, ], details: undefined, }; } try { const enc = (encoding ?? 'utf8') as BufferEncoding; await writeFile(safePath, content, { encoding: enc }); return { content: [{ type: 'text' as const, text: `File written successfully: ${path}` }], details: undefined, }; } catch (err) { return { content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }], details: undefined, }; } }, }; const listDirectoryTool: ToolDefinition = { name: 'fs_list_directory', label: 'List Directory', description: 'List files and directories at a given path within the sandbox base directory.', parameters: Type.Object({ path: Type.Optional( Type.String({ description: 'Directory path (relative to sandbox base). Defaults to base directory.', }), ), }), async execute(_toolCallId, params) { const { path } = params as { path?: string }; const target = path ?? '.'; let safePath: string; try { safePath = guardPath(target, baseDir); } catch (err) { if (err instanceof SandboxEscapeError) { return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], details: undefined, }; } return { content: [{ type: 'text' as const, text: `Error: ${String(err)}` }], details: undefined, }; } try { const info = await stat(safePath); if (!info.isDirectory()) { return { content: [{ type: 'text' as const, text: `Error: path is not a directory: ${target}` }], details: undefined, }; } const entries = await readdir(safePath, { withFileTypes: true }); const items = entries.map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : e.isSymbolicLink() ? 'symlink' : 'file', })); return { content: [{ type: 'text' as const, text: JSON.stringify(items, null, 2) }], details: undefined, }; } catch (err) { return { content: [{ type: 'text' as const, text: `Error listing directory: ${String(err)}` }], details: undefined, }; } }, }; return [readFileTool, writeFileTool, listDirectoryTool]; }