359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
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];
|
|
}
|