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, }; } }, }; const editFileTool: ToolDefinition = { name: 'fs_edit_file', label: 'Edit File', description: 'Make targeted text replacements in a file. Each edit replaces an exact match of oldText with newText. ' + 'All edits are matched against the original file content (not incrementally). ' + 'Each oldText must be unique in the file and edits must not overlap.', parameters: Type.Object({ path: Type.String({ description: 'File path (relative to sandbox base or absolute within it)', }), edits: Type.Array( Type.Object({ oldText: Type.String({ description: 'Exact text to find and replace (must be unique in the file)', }), newText: Type.String({ description: 'Replacement text' }), }), { description: 'One or more targeted replacements', minItems: 1 }, ), }), async execute(_toolCallId, params) { const { path, edits } = params as { path: string; edits: Array<{ oldText: string; newText: 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 for editing (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`, }, ], details: undefined, }; } } catch (err) { return { content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }], details: undefined, }; } let content: string; try { content = await readFile(safePath, { encoding: 'utf8' }); } catch (err) { return { content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }], details: undefined, }; } // Validate all edits before applying any const errors: string[] = []; for (let i = 0; i < edits.length; i++) { const edit = edits[i]!; const occurrences = content.split(edit.oldText).length - 1; if (occurrences === 0) { errors.push(`Edit ${i + 1}: oldText not found in file`); } else if (occurrences > 1) { errors.push(`Edit ${i + 1}: oldText matches ${occurrences} locations (must be unique)`); } } // Check for overlapping edits if (errors.length === 0) { const positions = edits.map((edit, i) => ({ index: i, start: content.indexOf(edit.oldText), end: content.indexOf(edit.oldText) + edit.oldText.length, })); positions.sort((a, b) => a.start - b.start); for (let i = 1; i < positions.length; i++) { if (positions[i]!.start < positions[i - 1]!.end) { errors.push( `Edits ${positions[i - 1]!.index + 1} and ${positions[i]!.index + 1} overlap`, ); } } } if (errors.length > 0) { return { content: [ { type: 'text' as const, text: `Edit validation failed:\n${errors.join('\n')}`, }, ], details: undefined, }; } // Apply edits: process from end to start to preserve positions const positions = edits.map((edit) => ({ edit, start: content.indexOf(edit.oldText), })); positions.sort((a, b) => b.start - a.start); // reverse order let result = content; for (const { edit } of positions) { result = result.replace(edit.oldText, edit.newText); } if (Buffer.byteLength(result, 'utf8') > MAX_WRITE_BYTES) { return { content: [ { type: 'text' as const, text: `Error: resulting file too large (limit ${MAX_WRITE_BYTES} bytes)`, }, ], details: undefined, }; } try { await writeFile(safePath, result, { encoding: 'utf8' }); return { content: [ { type: 'text' as const, text: `File edited successfully: ${path} (${edits.length} edit(s) applied)`, }, ], details: undefined, }; } catch (err) { return { content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }], details: undefined, }; } }, }; return [readFileTool, writeFileTool, listDirectoryTool, editFileTool]; }