feat(agent): expand tool registry — file, git, shell, web fetch (#126) (#138)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #138.
This commit is contained in:
2026-03-15 18:17:17 +00:00
committed by jason.woltje
parent 8633823257
commit 07647c8382
6 changed files with 760 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
import { resolve, relative, join } from 'node:path';
/**
* Safety constraint: all file operations are restricted to a base directory.
* Paths that escape the sandbox via ../ traversal are rejected.
*/
function resolveSafe(baseDir: string, inputPath: string): string {
const resolved = resolve(baseDir, inputPath);
const rel = relative(baseDir, resolved);
if (rel.startsWith('..') || resolve(resolved) !== resolve(join(baseDir, rel))) {
throw new Error(`Path escape detected: "${inputPath}" resolves outside base directory`);
}
return resolved;
}
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 = resolveSafe(baseDir, path);
} catch (err) {
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 = resolveSafe(baseDir, path);
} catch (err) {
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 = resolveSafe(baseDir, target);
} catch (err) {
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];
}