From 8398c857d3ae34d20f193c19f303feb48fc7fa04 Mon Sep 17 00:00:00 2001 From: 0xRozier Date: Fri, 13 Feb 2026 10:54:06 +0100 Subject: [PATCH 1/3] feat: add file system permission manager Implement path-based permission system persisted in .dexter/permissions.json. Supports exact file matches and recursive directory grants per tool type. Closes #129 (partial) Co-Authored-By: Claude Opus 4.6 --- src/utils/permissions.test.ts | 99 +++++++++++++++++++++++++ src/utils/permissions.ts | 135 ++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/utils/permissions.test.ts create mode 100644 src/utils/permissions.ts diff --git a/src/utils/permissions.test.ts b/src/utils/permissions.test.ts new file mode 100644 index 00000000..7a50198f --- /dev/null +++ b/src/utils/permissions.test.ts @@ -0,0 +1,99 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { existsSync, rmSync, readFileSync } from 'fs'; +import { resolve } from 'path'; +import { checkPermission, grantPermission, requestPermission } from './permissions.js'; + +const PERMISSIONS_PATH = '.dexter/permissions.json'; + +describe('permissions', () => { + beforeEach(() => { + if (existsSync(PERMISSIONS_PATH)) { + rmSync(PERMISSIONS_PATH); + } + }); + + afterEach(() => { + if (existsSync(PERMISSIONS_PATH)) { + rmSync(PERMISSIONS_PATH); + } + }); + + describe('checkPermission', () => { + test('returns false when no permissions are granted', () => { + expect(checkPermission('read_file', '/tmp/test.txt')).toBe(false); + }); + + test('returns true for exact path match', () => { + grantPermission('read_file', '/tmp/test.txt', false); + expect(checkPermission('read_file', '/tmp/test.txt')).toBe(true); + }); + + test('returns false for different tool on same path', () => { + grantPermission('read_file', '/tmp/test.txt', false); + expect(checkPermission('write_file', '/tmp/test.txt')).toBe(false); + }); + + test('returns true for file under recursive directory grant', () => { + grantPermission('read_file', '/tmp/project', true); + expect(checkPermission('read_file', '/tmp/project/src/index.ts')).toBe(true); + }); + + test('returns false for file outside recursive directory grant', () => { + grantPermission('read_file', '/tmp/project', true); + expect(checkPermission('read_file', '/tmp/other/file.txt')).toBe(false); + }); + + test('non-recursive grant does not match subdirectory files', () => { + grantPermission('read_file', '/tmp/project', false); + expect(checkPermission('read_file', '/tmp/project/file.txt')).toBe(false); + }); + }); + + describe('grantPermission', () => { + test('persists permission to disk', () => { + grantPermission('write_file', '/tmp/output.txt', false); + expect(existsSync(PERMISSIONS_PATH)).toBe(true); + + const raw = readFileSync(PERMISSIONS_PATH, 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.rules).toHaveLength(1); + expect(parsed.rules[0].tool).toBe('write_file'); + expect(parsed.rules[0].path).toBe(resolve('/tmp/output.txt')); + }); + + test('does not create duplicate rules', () => { + grantPermission('read_file', '/tmp/test.txt', false); + grantPermission('read_file', '/tmp/test.txt', false); + + const raw = readFileSync(PERMISSIONS_PATH, 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.rules).toHaveLength(1); + }); + + test('allows different tools for same path', () => { + grantPermission('read_file', '/tmp/test.txt', false); + grantPermission('write_file', '/tmp/test.txt', false); + + const raw = readFileSync(PERMISSIONS_PATH, 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.rules).toHaveLength(2); + }); + }); + + describe('requestPermission', () => { + test('returns allowed: true when permission exists', () => { + grantPermission('read_file', '/tmp/test.txt', false); + const result = requestPermission('read_file', '/tmp/test.txt'); + expect(result.allowed).toBe(true); + expect(result.message).toBeUndefined(); + }); + + test('returns allowed: false with helpful message when no permission', () => { + const result = requestPermission('read_file', '/tmp/test.txt'); + expect(result.allowed).toBe(false); + expect(result.message).toContain('Permission denied'); + expect(result.message).toContain('read_file'); + expect(result.message).toContain('.dexter/permissions.json'); + }); + }); +}); diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 00000000..c12bfc8e --- /dev/null +++ b/src/utils/permissions.ts @@ -0,0 +1,135 @@ +/** + * File system permission manager. + * + * Persists path-based permissions in .dexter/permissions.json so the user + * only has to grant access once per path. Permissions are checked before + * every file tool invocation. + */ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { resolve, dirname } from 'path'; + +// ============================================================================ +// Types +// ============================================================================ + +export type PermissionTool = 'read_file' | 'write_file' | 'edit_file'; + +export interface PermissionRule { + /** Tool this rule applies to */ + tool: PermissionTool; + /** Absolute path (file or directory) */ + path: string; + /** If true, grants access to all files under `path` */ + recursive: boolean; +} + +interface PermissionsFile { + rules: PermissionRule[]; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const PERMISSIONS_PATH = '.dexter/permissions.json'; + +// ============================================================================ +// Persistence +// ============================================================================ + +function loadPermissions(): PermissionsFile { + if (!existsSync(PERMISSIONS_PATH)) { + return { rules: [] }; + } + try { + const raw = readFileSync(PERMISSIONS_PATH, 'utf-8'); + const parsed: unknown = JSON.parse(raw); + if ( + parsed && + typeof parsed === 'object' && + 'rules' in parsed && + Array.isArray((parsed as PermissionsFile).rules) + ) { + return parsed as PermissionsFile; + } + return { rules: [] }; + } catch { + return { rules: [] }; + } +} + +function savePermissions(perms: PermissionsFile): void { + const dir = dirname(PERMISSIONS_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(PERMISSIONS_PATH, JSON.stringify(perms, null, 2)); +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Check whether the given tool is allowed to access `filePath`. + */ +export function checkPermission(tool: PermissionTool, filePath: string): boolean { + const absPath = resolve(filePath); + const perms = loadPermissions(); + + for (const rule of perms.rules) { + if (rule.tool !== tool) continue; + + const rulePath = resolve(rule.path); + + // Exact match + if (absPath === rulePath) return true; + + // Recursive directory match + if (rule.recursive && absPath.startsWith(rulePath + '/')) return true; + } + + return false; +} + +/** + * Grant a permission and persist it to disk. + */ +export function grantPermission(tool: PermissionTool, path: string, recursive: boolean): void { + const absPath = resolve(path); + const perms = loadPermissions(); + + // Avoid duplicates + const exists = perms.rules.some( + (r) => r.tool === tool && resolve(r.path) === absPath && r.recursive === recursive, + ); + if (!exists) { + perms.rules.push({ tool, path: absPath, recursive }); + savePermissions(perms); + } +} + +/** + * Check permission and return a structured result the tool can use. + */ +export function requestPermission( + tool: PermissionTool, + filePath: string, +): { allowed: boolean; message?: string } { + if (checkPermission(tool, filePath)) { + return { allowed: true }; + } + + const absPath = resolve(filePath); + const parentDir = dirname(absPath); + + return { + allowed: false, + message: + `Permission denied: ${tool} is not allowed to access "${absPath}".\n\n` + + `To grant access, add a rule to .dexter/permissions.json:\n` + + `{\n "rules": [\n { "tool": "${tool}", "path": "${parentDir}", "recursive": true }\n ]\n}\n\n` + + `Or grant access to the specific file:\n` + + `{ "tool": "${tool}", "path": "${absPath}", "recursive": false }`, + }; +} From 725edf94300eb3d651415cb79486ce8162d87028 Mon Sep 17 00:00:00 2001 From: 0xRozier Date: Fri, 13 Feb 2026 10:54:16 +0100 Subject: [PATCH 2/3] feat: add read_file, write_file, and edit_file tools Implement three file system tools using LangChain DynamicStructuredTool: - read_file: reads files with line numbers, offset/limit support - write_file: creates/overwrites files, auto-creates parent dirs - edit_file: exact string replacement with uniqueness validation All tools check permissions before operating. Includes comprehensive tests. Closes #129 (partial) Co-Authored-By: Claude Opus 4.6 --- src/tools/descriptions/filesystem.ts | 102 ++++++++++ src/tools/filesystem/edit-file.ts | 90 +++++++++ src/tools/filesystem/filesystem.test.ts | 247 ++++++++++++++++++++++++ src/tools/filesystem/index.ts | 3 + src/tools/filesystem/read-file.ts | 100 ++++++++++ src/tools/filesystem/write-file.ts | 49 +++++ 6 files changed, 591 insertions(+) create mode 100644 src/tools/descriptions/filesystem.ts create mode 100644 src/tools/filesystem/edit-file.ts create mode 100644 src/tools/filesystem/filesystem.test.ts create mode 100644 src/tools/filesystem/index.ts create mode 100644 src/tools/filesystem/read-file.ts create mode 100644 src/tools/filesystem/write-file.ts diff --git a/src/tools/descriptions/filesystem.ts b/src/tools/descriptions/filesystem.ts new file mode 100644 index 00000000..72a2690c --- /dev/null +++ b/src/tools/descriptions/filesystem.ts @@ -0,0 +1,102 @@ +/** + * Rich descriptions for the file system tools (read_file, write_file, edit_file). + * Used in the system prompt to guide the LLM on when and how to use these tools. + */ + +export const READ_FILE_DESCRIPTION = ` +Read a file from the local file system. Returns content with line numbers. + +## When to Use + +- Reading source code, configuration files, or data files +- Inspecting file contents before making edits +- Reviewing project structure and code organization + +## When NOT to Use + +- Reading web pages or remote URLs (use web_fetch instead) +- Reading SEC filings (use read_filings instead) +- Listing directory contents (use a shell command instead) + +## Schema + +- **file_path** (required): Absolute or relative path to the file +- **offset** (optional): Line number to start reading from (0-based, default 0) +- **limit** (optional): Maximum number of lines to read (default 2000) + +## Returns + +File content with line numbers (cat -n style). Includes metadata about total lines and truncation. + +## Usage Notes + +- Always read a file before editing it to understand its current content +- Use offset and limit for large files to avoid overwhelming the context +- Lines longer than 2000 characters are truncated +- Requires file system permissions — see .dexter/permissions.json +`.trim(); + +export const WRITE_FILE_DESCRIPTION = ` +Write content to a file on the local file system. Creates the file or overwrites it. + +## When to Use + +- Creating new files (scripts, configurations, data files) +- Overwriting a file with entirely new content +- Saving generated output to disk + +## When NOT to Use + +- Making small edits to an existing file (use edit_file instead — it's safer) +- Appending to a file (read first, then write the combined content) + +## Schema + +- **file_path** (required): Absolute or relative path to the file +- **content** (required): The content to write to the file + +## Returns + +Confirmation with file path and bytes written. + +## Usage Notes + +- Creates parent directories automatically if they don't exist +- Overwrites the file if it already exists — be careful with existing files +- Prefer edit_file for targeted changes to avoid accidentally losing content +- Requires file system permissions — see .dexter/permissions.json +`.trim(); + +export const EDIT_FILE_DESCRIPTION = ` +Perform exact string replacement in a file. Finds old_string and replaces it with new_string. + +## When to Use + +- Making targeted edits to existing files +- Fixing bugs, updating values, or refactoring code +- Renaming variables or strings across a file (with replace_all) + +## When NOT to Use + +- Creating a new file from scratch (use write_file instead) +- Rewriting most of a file's content (use write_file instead) + +## Schema + +- **file_path** (required): Absolute or relative path to the file +- **old_string** (required): The exact text to find and replace +- **new_string** (required): The replacement text +- **replace_all** (optional): If true, replace all occurrences (default false) + +## Returns + +Confirmation with the number of replacements made. + +## Usage Notes + +- Always read the file first so you know the exact content to match +- old_string must match exactly, including whitespace and indentation +- By default, old_string must appear exactly once (prevents accidental mass edits) +- Use replace_all: true to replace all occurrences (e.g., renaming a variable) +- Requires file system permissions — see .dexter/permissions.json +`.trim(); diff --git a/src/tools/filesystem/edit-file.ts b/src/tools/filesystem/edit-file.ts new file mode 100644 index 00000000..7bef9c8a --- /dev/null +++ b/src/tools/filesystem/edit-file.ts @@ -0,0 +1,90 @@ +/** + * edit_file tool — performs exact string replacements in a file. + * + * Checks permissions before editing. Validates that old_string exists and + * is unique (unless replace_all is true). + */ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { formatToolResult } from '../types.js'; +import { requestPermission } from '../../utils/permissions.js'; + +export const editFileTool = new DynamicStructuredTool({ + name: 'edit_file', + description: + 'Perform exact string replacement in a file. Finds old_string in the file and replaces it with new_string. By default, old_string must appear exactly once (use replace_all for multiple occurrences).', + schema: z.object({ + file_path: z.string().describe('Absolute or relative path to the file to edit.'), + old_string: z.string().describe('The exact text to find and replace.'), + new_string: z.string().describe('The replacement text.'), + replace_all: z + .boolean() + .optional() + .default(false) + .describe('If true, replace all occurrences. If false (default), old_string must be unique.'), + }), + func: async (input) => { + const filePath = resolve(input.file_path); + + // Check permissions + const perm = requestPermission('edit_file', filePath); + if (!perm.allowed) { + return perm.message!; + } + + if (!existsSync(filePath)) { + return `Error: File not found: ${filePath}`; + } + + if (input.old_string === input.new_string) { + return 'Error: old_string and new_string are identical. No changes needed.'; + } + + try { + const content = readFileSync(filePath, 'utf-8'); + + // Count occurrences + let count = 0; + let idx = 0; + while ((idx = content.indexOf(input.old_string, idx)) !== -1) { + count++; + idx += input.old_string.length; + } + + if (count === 0) { + return `Error: old_string not found in ${filePath}. Make sure the string matches exactly (including whitespace and indentation).`; + } + + if (count > 1 && !input.replace_all) { + return ( + `Error: old_string appears ${count} times in ${filePath}. ` + + `Provide more surrounding context to make it unique, or set replace_all to true.` + ); + } + + // Perform replacement + let newContent: string; + if (input.replace_all) { + newContent = content.split(input.old_string).join(input.new_string); + } else { + // Replace first (and only) occurrence + const pos = content.indexOf(input.old_string); + newContent = + content.slice(0, pos) + input.new_string + content.slice(pos + input.old_string.length); + } + + writeFileSync(filePath, newContent, 'utf-8'); + + return formatToolResult({ + file_path: filePath, + replacements: count, + message: `Successfully replaced ${count} occurrence${count !== 1 ? 's' : ''} in ${filePath}`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error editing file: ${message}`; + } + }, +}); diff --git a/src/tools/filesystem/filesystem.test.ts b/src/tools/filesystem/filesystem.test.ts new file mode 100644 index 00000000..d7f36743 --- /dev/null +++ b/src/tools/filesystem/filesystem.test.ts @@ -0,0 +1,247 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { join, resolve } from 'path'; +import { grantPermission } from '../../utils/permissions.js'; +import { readFileTool } from './read-file.js'; +import { writeFileTool } from './write-file.js'; +import { editFileTool } from './edit-file.js'; + +const TEST_DIR = '.dexter/test-filesystem'; +const PERMISSIONS_PATH = '.dexter/permissions.json'; + +function setupTestDir() { + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }); + } +} + +function cleanup() { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }); + } + if (existsSync(PERMISSIONS_PATH)) { + rmSync(PERMISSIONS_PATH); + } +} + +/** + * Grant permissions for test directory so tools can operate. + */ +function grantTestPermissions() { + const absDir = resolve(TEST_DIR); + grantPermission('read_file', absDir, true); + grantPermission('write_file', absDir, true); + grantPermission('edit_file', absDir, true); +} + +// --------------------------------------------------------------------------- +// read_file +// --------------------------------------------------------------------------- + +describe('read_file', () => { + beforeEach(() => { + cleanup(); + setupTestDir(); + grantTestPermissions(); + }); + + afterEach(cleanup); + + test('reads an existing file with line numbers', async () => { + const filePath = join(TEST_DIR, 'hello.txt'); + writeFileSync(filePath, 'line one\nline two\nline three'); + + const result = await readFileTool.invoke({ file_path: filePath }); + expect(result).toContain('line one'); + expect(result).toContain('line two'); + expect(result).toContain('line three'); + }); + + test('returns error for missing file', async () => { + const result = await readFileTool.invoke({ file_path: join(TEST_DIR, 'missing.txt') }); + expect(result).toContain('Error: File not found'); + }); + + test('returns error for directory', async () => { + const result = await readFileTool.invoke({ file_path: TEST_DIR }); + expect(result).toContain('is a directory'); + }); + + test('respects offset and limit', async () => { + const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join('\n'); + const filePath = join(TEST_DIR, 'lines.txt'); + writeFileSync(filePath, lines); + + const result = await readFileTool.invoke({ file_path: filePath, offset: 2, limit: 3 }); + expect(result).toContain('line 3'); + expect(result).toContain('line 4'); + expect(result).toContain('line 5'); + expect(result).not.toContain('line 1'); + expect(result).not.toContain('line 6'); + }); + + test('returns permission denied without permission', async () => { + // Remove permissions + rmSync(PERMISSIONS_PATH); + + const filePath = join(TEST_DIR, 'hello.txt'); + writeFileSync(filePath, 'content'); + + const result = await readFileTool.invoke({ file_path: filePath }); + expect(result).toContain('Permission denied'); + }); +}); + +// --------------------------------------------------------------------------- +// write_file +// --------------------------------------------------------------------------- + +describe('write_file', () => { + beforeEach(() => { + cleanup(); + setupTestDir(); + grantTestPermissions(); + }); + + afterEach(cleanup); + + test('writes a new file', async () => { + const filePath = join(TEST_DIR, 'output.txt'); + const result = await writeFileTool.invoke({ file_path: filePath, content: 'hello world' }); + + expect(result).toContain('Successfully wrote'); + expect(readFileSync(filePath, 'utf-8')).toBe('hello world'); + }); + + test('creates parent directories', async () => { + const filePath = join(TEST_DIR, 'sub/dir/file.txt'); + await writeFileTool.invoke({ file_path: filePath, content: 'nested' }); + + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, 'utf-8')).toBe('nested'); + }); + + test('overwrites existing file', async () => { + const filePath = join(TEST_DIR, 'overwrite.txt'); + writeFileSync(filePath, 'old content'); + + await writeFileTool.invoke({ file_path: filePath, content: 'new content' }); + expect(readFileSync(filePath, 'utf-8')).toBe('new content'); + }); + + test('returns permission denied without permission', async () => { + rmSync(PERMISSIONS_PATH); + + const result = await writeFileTool.invoke({ + file_path: join(TEST_DIR, 'file.txt'), + content: 'test', + }); + expect(result).toContain('Permission denied'); + }); +}); + +// --------------------------------------------------------------------------- +// edit_file +// --------------------------------------------------------------------------- + +describe('edit_file', () => { + beforeEach(() => { + cleanup(); + setupTestDir(); + grantTestPermissions(); + }); + + afterEach(cleanup); + + test('replaces a unique string', async () => { + const filePath = join(TEST_DIR, 'edit.txt'); + writeFileSync(filePath, 'hello world'); + + const result = await editFileTool.invoke({ + file_path: filePath, + old_string: 'hello', + new_string: 'goodbye', + }); + + expect(result).toContain('Successfully replaced 1'); + expect(readFileSync(filePath, 'utf-8')).toBe('goodbye world'); + }); + + test('errors when old_string not found', async () => { + const filePath = join(TEST_DIR, 'edit.txt'); + writeFileSync(filePath, 'hello world'); + + const result = await editFileTool.invoke({ + file_path: filePath, + old_string: 'missing', + new_string: 'replacement', + }); + + expect(result).toContain('old_string not found'); + }); + + test('errors on ambiguous match without replace_all', async () => { + const filePath = join(TEST_DIR, 'edit.txt'); + writeFileSync(filePath, 'foo bar foo baz foo'); + + const result = await editFileTool.invoke({ + file_path: filePath, + old_string: 'foo', + new_string: 'qux', + }); + + expect(result).toContain('appears 3 times'); + }); + + test('replaces all occurrences with replace_all', async () => { + const filePath = join(TEST_DIR, 'edit.txt'); + writeFileSync(filePath, 'foo bar foo baz foo'); + + const result = await editFileTool.invoke({ + file_path: filePath, + old_string: 'foo', + new_string: 'qux', + replace_all: true, + }); + + expect(result).toContain('Successfully replaced 3'); + expect(readFileSync(filePath, 'utf-8')).toBe('qux bar qux baz qux'); + }); + + test('errors when old_string equals new_string', async () => { + const filePath = join(TEST_DIR, 'edit.txt'); + writeFileSync(filePath, 'hello'); + + const result = await editFileTool.invoke({ + file_path: filePath, + old_string: 'hello', + new_string: 'hello', + }); + + expect(result).toContain('identical'); + }); + + test('errors for missing file', async () => { + const result = await editFileTool.invoke({ + file_path: join(TEST_DIR, 'missing.txt'), + old_string: 'a', + new_string: 'b', + }); + + expect(result).toContain('File not found'); + }); + + test('returns permission denied without permission', async () => { + rmSync(PERMISSIONS_PATH); + const filePath = join(TEST_DIR, 'edit.txt'); + writeFileSync(filePath, 'hello'); + + const result = await editFileTool.invoke({ + file_path: filePath, + old_string: 'hello', + new_string: 'bye', + }); + + expect(result).toContain('Permission denied'); + }); +}); diff --git a/src/tools/filesystem/index.ts b/src/tools/filesystem/index.ts new file mode 100644 index 00000000..8f1f7561 --- /dev/null +++ b/src/tools/filesystem/index.ts @@ -0,0 +1,3 @@ +export { readFileTool } from './read-file.js'; +export { writeFileTool } from './write-file.js'; +export { editFileTool } from './edit-file.js'; diff --git a/src/tools/filesystem/read-file.ts b/src/tools/filesystem/read-file.ts new file mode 100644 index 00000000..4c0d8d29 --- /dev/null +++ b/src/tools/filesystem/read-file.ts @@ -0,0 +1,100 @@ +/** + * read_file tool — reads a file from the local file system. + * + * Returns file content with line numbers. Checks permissions before reading. + */ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; +import { readFileSync, existsSync, statSync } from 'fs'; +import { resolve } from 'path'; +import { formatToolResult } from '../types.js'; +import { requestPermission } from '../../utils/permissions.js'; + +const DEFAULT_LINE_LIMIT = 2000; +const MAX_LINE_LENGTH = 2000; + +/** + * Format file content with line numbers (cat -n style). + */ +function formatWithLineNumbers(content: string, offset: number, limit: number): string { + const lines = content.split('\n'); + const start = Math.max(0, offset); + const end = Math.min(lines.length, start + limit); + const selected = lines.slice(start, end); + + // Determine padding width for line numbers + const maxLineNum = start + selected.length; + const padWidth = String(maxLineNum).length; + + return selected + .map((line, i) => { + const lineNum = String(start + i + 1).padStart(padWidth, ' '); + const truncated = line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + '...' : line; + return `${lineNum}\t${truncated}`; + }) + .join('\n'); +} + +export const readFileTool = new DynamicStructuredTool({ + name: 'read_file', + description: 'Read a file from the local file system. Returns content with line numbers.', + schema: z.object({ + file_path: z.string().describe('Absolute or relative path to the file to read.'), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Line number to start reading from (0-based). Defaults to 0.'), + limit: z + .number() + .int() + .min(1) + .optional() + .describe(`Maximum number of lines to read. Defaults to ${DEFAULT_LINE_LIMIT}.`), + }), + func: async (input) => { + const filePath = resolve(input.file_path); + + // Check permissions + const perm = requestPermission('read_file', filePath); + if (!perm.allowed) { + return perm.message!; + } + + // Validate file exists + if (!existsSync(filePath)) { + return `Error: File not found: ${filePath}`; + } + + const stat = statSync(filePath); + if (stat.isDirectory()) { + return `Error: "${filePath}" is a directory, not a file. Use a shell command to list directory contents.`; + } + + try { + const content = readFileSync(filePath, 'utf-8'); + const offset = input.offset ?? 0; + const limit = input.limit ?? DEFAULT_LINE_LIMIT; + const totalLines = content.split('\n').length; + const formatted = formatWithLineNumbers(content, offset, limit); + + const truncated = offset + limit < totalLines; + const meta: Record = { + file_path: filePath, + total_lines: totalLines, + offset, + lines_returned: Math.min(limit, Math.max(0, totalLines - offset)), + }; + if (truncated) { + meta.truncated = true; + meta.hint = `File has ${totalLines} lines. Use offset=${offset + limit} to read more.`; + } + + return formatToolResult({ ...meta, content: formatted }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error reading file: ${message}`; + } + }, +}); diff --git a/src/tools/filesystem/write-file.ts b/src/tools/filesystem/write-file.ts new file mode 100644 index 00000000..99067845 --- /dev/null +++ b/src/tools/filesystem/write-file.ts @@ -0,0 +1,49 @@ +/** + * write_file tool — writes content to a file on the local file system. + * + * Creates parent directories if needed. Checks permissions before writing. + */ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; +import { writeFileSync, mkdirSync, existsSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { formatToolResult } from '../types.js'; +import { requestPermission } from '../../utils/permissions.js'; + +export const writeFileTool = new DynamicStructuredTool({ + name: 'write_file', + description: + 'Write content to a file on the local file system. Creates the file if it does not exist, or overwrites it if it does. Creates parent directories as needed.', + schema: z.object({ + file_path: z.string().describe('Absolute or relative path to the file to write.'), + content: z.string().describe('The content to write to the file.'), + }), + func: async (input) => { + const filePath = resolve(input.file_path); + + // Check permissions + const perm = requestPermission('write_file', filePath); + if (!perm.allowed) { + return perm.message!; + } + + try { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(filePath, input.content, 'utf-8'); + const bytes = Buffer.byteLength(input.content, 'utf-8'); + + return formatToolResult({ + file_path: filePath, + bytes_written: bytes, + message: `Successfully wrote ${bytes} bytes to ${filePath}`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error writing file: ${message}`; + } + }, +}); From 082a099d660b704e283318deae641356b1e62c43 Mon Sep 17 00:00:00 2001 From: 0xRozier Date: Fri, 13 Feb 2026 10:54:24 +0100 Subject: [PATCH 3/3] feat: register file tools in tool registry Add read_file, write_file, and edit_file to the tool registry so they are available to the agent. Tools are always enabled (no env var gate). Closes #129 Co-Authored-By: Claude Opus 4.6 --- src/tools/descriptions/index.ts | 1 + src/tools/index.ts | 6 ++++++ src/tools/registry.ts | 18 +++++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/tools/descriptions/index.ts b/src/tools/descriptions/index.ts index 08d59cd2..fd5695c5 100644 --- a/src/tools/descriptions/index.ts +++ b/src/tools/descriptions/index.ts @@ -8,3 +8,4 @@ export { WEB_SEARCH_DESCRIPTION } from './web-search.js'; export { READ_FILINGS_DESCRIPTION } from './read-filings.js'; export { WEB_FETCH_DESCRIPTION } from './web-fetch.js'; export { BROWSER_DESCRIPTION } from './browser.js'; +export { READ_FILE_DESCRIPTION, WRITE_FILE_DESCRIPTION, EDIT_FILE_DESCRIPTION } from './filesystem.js'; diff --git a/src/tools/index.ts b/src/tools/index.ts index 2c07d455..a81f30c6 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,8 +6,14 @@ export type { RegisteredTool } from './registry.js'; export { createFinancialSearch } from './finance/index.js'; export { tavilySearch } from './search/index.js'; +// File system tools +export { readFileTool, writeFileTool, editFileTool } from './filesystem/index.js'; + // Tool descriptions export { FINANCIAL_SEARCH_DESCRIPTION, WEB_SEARCH_DESCRIPTION, + READ_FILE_DESCRIPTION, + WRITE_FILE_DESCRIPTION, + EDIT_FILE_DESCRIPTION, } from './descriptions/index.js'; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index d62ccd57..874d35b6 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -4,7 +4,8 @@ import { exaSearch, perplexitySearch, tavilySearch } from './search/index.js'; import { skillTool, SKILL_TOOL_DESCRIPTION } from './skill.js'; import { webFetchTool } from './fetch/index.js'; import { browserTool } from './browser/index.js'; -import { FINANCIAL_SEARCH_DESCRIPTION, FINANCIAL_METRICS_DESCRIPTION, WEB_SEARCH_DESCRIPTION, WEB_FETCH_DESCRIPTION, READ_FILINGS_DESCRIPTION, BROWSER_DESCRIPTION } from './descriptions/index.js'; +import { readFileTool, writeFileTool, editFileTool } from './filesystem/index.js'; +import { FINANCIAL_SEARCH_DESCRIPTION, FINANCIAL_METRICS_DESCRIPTION, WEB_SEARCH_DESCRIPTION, WEB_FETCH_DESCRIPTION, READ_FILINGS_DESCRIPTION, BROWSER_DESCRIPTION, READ_FILE_DESCRIPTION, WRITE_FILE_DESCRIPTION, EDIT_FILE_DESCRIPTION } from './descriptions/index.js'; import { discoverSkills } from '../skills/index.js'; /** @@ -53,6 +54,21 @@ export function getToolRegistry(model: string): RegisteredTool[] { tool: browserTool, description: BROWSER_DESCRIPTION, }, + { + name: 'read_file', + tool: readFileTool, + description: READ_FILE_DESCRIPTION, + }, + { + name: 'write_file', + tool: writeFileTool, + description: WRITE_FILE_DESCRIPTION, + }, + { + name: 'edit_file', + tool: editFileTool, + description: EDIT_FILE_DESCRIPTION, + }, ]; // Include web_search if Exa, Perplexity, or Tavily API key is configured (Exa → Perplexity → Tavily)