feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,641 @@
/**
* Directory Approval Integration Tests
*
* Tests for the directory access permission system integrated into file tools.
*
* Key behaviors tested:
* 1. Working directory: No directory prompt, normal tool flow
* 2. External dir (first access): Directory prompt via getApprovalOverride
* 3. External dir (after "session" approval): No directory prompt
* 4. External dir (after "once" approval): Directory prompt every time
* 5. Path containment: approving /ext covers /ext/sub/file.txt
*/
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { createReadFileTool } from './read-file-tool.js';
import { createWriteFileTool } from './write-file-tool.js';
import { createEditFileTool } from './edit-file-tool.js';
import { FileSystemService } from './filesystem-service.js';
import type { DirectoryApprovalCallbacks, FileToolOptions } from './file-tool-types.js';
import { ApprovalType, ApprovalStatus } from '@dexto/core';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('Directory Approval Integration Tests', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
let fileSystemService: FileSystemService;
let directoryApproval: DirectoryApprovalCallbacks;
let isSessionApprovedMock: Mock;
let addApprovedMock: Mock;
beforeEach(async () => {
mockLogger = createMockLogger();
// Create temp directory for testing
// Resolve real path to handle symlinks (macOS /tmp -> /private/var/folders/...)
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-fs-test-'));
tempDir = await fs.realpath(rawTempDir);
// Create FileSystemService with temp dir as working directory
fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: false,
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
// Create directory approval callbacks
isSessionApprovedMock = vi.fn().mockReturnValue(false);
addApprovedMock = vi.fn();
directoryApproval = {
isSessionApproved: isSessionApprovedMock,
addApproved: addApprovedMock,
};
vi.clearAllMocks();
});
afterEach(async () => {
// Cleanup temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
// =====================================================================
// READ FILE TOOL TESTS
// =====================================================================
describe('Read File Tool', () => {
describe('getApprovalOverride', () => {
it('should return null for paths within working directory (no prompt needed)', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
// Create test file in working directory
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'test content');
const override = await tool.getApprovalOverride?.({ file_path: testFile });
expect(override).toBeNull();
});
it('should return directory access approval for external paths', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
// External path (outside working directory)
const externalPath = '/external/project/file.ts';
const override = await tool.getApprovalOverride?.({ file_path: externalPath });
expect(override).not.toBeNull();
expect(override?.type).toBe(ApprovalType.DIRECTORY_ACCESS);
const metadata = override?.metadata as any;
expect(metadata?.path).toBe(path.resolve(externalPath));
expect(metadata?.parentDir).toBe(path.dirname(path.resolve(externalPath)));
expect(metadata?.operation).toBe('read');
expect(metadata?.toolName).toBe('read_file');
});
it('should return null when external path is session-approved', async () => {
isSessionApprovedMock.mockReturnValue(true);
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
const externalPath = '/external/project/file.ts';
const override = await tool.getApprovalOverride?.({ file_path: externalPath });
expect(override).toBeNull();
expect(isSessionApprovedMock).toHaveBeenCalledWith(externalPath);
});
it('should return null when file_path is missing', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
const override = await tool.getApprovalOverride?.({});
expect(override).toBeNull();
});
});
describe('onApprovalGranted', () => {
it('should add directory as session-approved when rememberDirectory is true', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
// First trigger getApprovalOverride to set pendingApprovalParentDir
const externalPath = '/external/project/file.ts';
await tool.getApprovalOverride?.({ file_path: externalPath });
// Then call onApprovalGranted with rememberDirectory: true
tool.onApprovalGranted?.({
approvalId: 'test-approval',
status: ApprovalStatus.APPROVED,
data: { rememberDirectory: true },
});
expect(addApprovedMock).toHaveBeenCalledWith(
path.dirname(path.resolve(externalPath)),
'session'
);
});
it('should add directory as once-approved when rememberDirectory is false', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
const externalPath = '/external/project/file.ts';
await tool.getApprovalOverride?.({ file_path: externalPath });
tool.onApprovalGranted?.({
approvalId: 'test-approval',
status: ApprovalStatus.APPROVED,
data: { rememberDirectory: false },
});
expect(addApprovedMock).toHaveBeenCalledWith(
path.dirname(path.resolve(externalPath)),
'once'
);
});
it('should default to once-approved when rememberDirectory is not specified', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
const externalPath = '/external/project/file.ts';
await tool.getApprovalOverride?.({ file_path: externalPath });
tool.onApprovalGranted?.({
approvalId: 'test-approval',
status: ApprovalStatus.APPROVED,
data: {},
});
expect(addApprovedMock).toHaveBeenCalledWith(
path.dirname(path.resolve(externalPath)),
'once'
);
});
it('should not call addApproved when directoryApproval is not provided', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval: undefined,
});
const externalPath = '/external/project/file.ts';
await tool.getApprovalOverride?.({ file_path: externalPath });
// Should not throw
tool.onApprovalGranted?.({
approvalId: 'test-approval',
status: ApprovalStatus.APPROVED,
data: { rememberDirectory: true },
});
expect(addApprovedMock).not.toHaveBeenCalled();
});
});
describe('execute', () => {
it('should read file contents within working directory', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
const testFile = path.join(tempDir, 'readable.txt');
await fs.writeFile(testFile, 'Hello, world!\nLine 2');
const result = (await tool.execute({ file_path: testFile }, {})) as any;
expect(result.content).toBe('Hello, world!\nLine 2');
expect(result.lines).toBe(2);
});
});
});
// =====================================================================
// WRITE FILE TOOL TESTS
// =====================================================================
describe('Write File Tool', () => {
describe('getApprovalOverride', () => {
it('should return null for paths within working directory', async () => {
const tool = createWriteFileTool({
fileSystemService,
directoryApproval,
});
const testFile = path.join(tempDir, 'new-file.txt');
const override = await tool.getApprovalOverride?.({
file_path: testFile,
content: 'test',
});
expect(override).toBeNull();
});
it('should return directory access approval for external paths', async () => {
const tool = createWriteFileTool({
fileSystemService,
directoryApproval,
});
const externalPath = '/external/project/new.ts';
const override = await tool.getApprovalOverride?.({
file_path: externalPath,
content: 'test',
});
expect(override).not.toBeNull();
expect(override?.type).toBe(ApprovalType.DIRECTORY_ACCESS);
const metadata = override?.metadata as any;
expect(metadata?.operation).toBe('write');
expect(metadata?.toolName).toBe('write_file');
});
it('should return null when external path is session-approved', async () => {
isSessionApprovedMock.mockReturnValue(true);
const tool = createWriteFileTool({
fileSystemService,
directoryApproval,
});
const externalPath = '/external/project/new.ts';
const override = await tool.getApprovalOverride?.({
file_path: externalPath,
content: 'test',
});
expect(override).toBeNull();
});
});
describe('onApprovalGranted', () => {
it('should add directory as session-approved when rememberDirectory is true', async () => {
const tool = createWriteFileTool({
fileSystemService,
directoryApproval,
});
const externalPath = '/external/project/new.ts';
await tool.getApprovalOverride?.({ file_path: externalPath, content: 'test' });
tool.onApprovalGranted?.({
approvalId: 'test-approval',
status: ApprovalStatus.APPROVED,
data: { rememberDirectory: true },
});
expect(addApprovedMock).toHaveBeenCalledWith(
path.dirname(path.resolve(externalPath)),
'session'
);
});
});
});
// =====================================================================
// EDIT FILE TOOL TESTS
// =====================================================================
describe('Edit File Tool', () => {
describe('getApprovalOverride', () => {
it('should return null for paths within working directory', async () => {
const tool = createEditFileTool({
fileSystemService,
directoryApproval,
});
const testFile = path.join(tempDir, 'existing.txt');
const override = await tool.getApprovalOverride?.({
file_path: testFile,
old_string: 'old',
new_string: 'new',
});
expect(override).toBeNull();
});
it('should return directory access approval for external paths', async () => {
const tool = createEditFileTool({
fileSystemService,
directoryApproval,
});
const externalPath = '/external/project/existing.ts';
const override = await tool.getApprovalOverride?.({
file_path: externalPath,
old_string: 'old',
new_string: 'new',
});
expect(override).not.toBeNull();
expect(override?.type).toBe(ApprovalType.DIRECTORY_ACCESS);
const metadata = override?.metadata as any;
expect(metadata?.operation).toBe('edit');
expect(metadata?.toolName).toBe('edit_file');
});
it('should return null when external path is session-approved', async () => {
isSessionApprovedMock.mockReturnValue(true);
const tool = createEditFileTool({
fileSystemService,
directoryApproval,
});
const externalPath = '/external/project/existing.ts';
const override = await tool.getApprovalOverride?.({
file_path: externalPath,
old_string: 'old',
new_string: 'new',
});
expect(override).toBeNull();
});
});
});
// =====================================================================
// SESSION VS ONCE APPROVAL SCENARIOS
// =====================================================================
describe('Session vs Once Approval Scenarios', () => {
it('should not prompt for subsequent requests after session approval', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
const externalPath1 = '/external/project/file1.ts';
const externalPath2 = '/external/project/file2.ts';
// First request - needs approval
let override = await tool.getApprovalOverride?.({ file_path: externalPath1 });
expect(override).not.toBeNull();
// Simulate session approval
tool.onApprovalGranted?.({
approvalId: 'approval-1',
status: ApprovalStatus.APPROVED,
data: { rememberDirectory: true },
});
// Verify addApproved was called with 'session'
expect(addApprovedMock).toHaveBeenCalledWith(
path.dirname(path.resolve(externalPath1)),
'session'
);
// Now simulate that isSessionApproved returns true for the approved directory
isSessionApprovedMock.mockReturnValue(true);
// Second request - should not need approval (session approved)
override = await tool.getApprovalOverride?.({ file_path: externalPath2 });
expect(override).toBeNull();
});
it('should prompt for subsequent requests after once approval', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
const externalPath1 = '/external/project/file1.ts';
const externalPath2 = '/external/project/file2.ts';
// First request - needs approval
let override = await tool.getApprovalOverride?.({ file_path: externalPath1 });
expect(override).not.toBeNull();
// Simulate once approval
tool.onApprovalGranted?.({
approvalId: 'approval-1',
status: ApprovalStatus.APPROVED,
data: { rememberDirectory: false },
});
// Verify addApproved was called with 'once'
expect(addApprovedMock).toHaveBeenCalledWith(
path.dirname(path.resolve(externalPath1)),
'once'
);
// isSessionApproved stays false for 'once' approvals
isSessionApprovedMock.mockReturnValue(false);
// Second request - should still need approval (only 'once')
override = await tool.getApprovalOverride?.({ file_path: externalPath2 });
expect(override).not.toBeNull();
});
});
// =====================================================================
// PATH CONTAINMENT SCENARIOS
// =====================================================================
describe('Path Containment Scenarios', () => {
it('should cover child paths when parent directory is session-approved', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
// isSessionApproved checks if file is within any session-approved directory
// If /external/project is session-approved, /external/project/deep/file.ts should also be covered
isSessionApprovedMock.mockImplementation((filePath: string) => {
const normalizedPath = path.resolve(filePath);
const approvedDir = '/external/project';
return (
normalizedPath.startsWith(approvedDir + path.sep) ||
normalizedPath === approvedDir
);
});
// Direct child path - should be approved
let override = await tool.getApprovalOverride?.({
file_path: '/external/project/file.ts',
});
expect(override).toBeNull();
// Deep nested path - should also be approved
override = await tool.getApprovalOverride?.({
file_path: '/external/project/deep/nested/file.ts',
});
expect(override).toBeNull();
});
it('should NOT cover sibling directories', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
// Only /external/sub is approved
isSessionApprovedMock.mockImplementation((filePath: string) => {
const normalizedPath = path.resolve(filePath);
const approvedDir = '/external/sub';
return (
normalizedPath.startsWith(approvedDir + path.sep) ||
normalizedPath === approvedDir
);
});
// /external/sub path - approved
let override = await tool.getApprovalOverride?.({ file_path: '/external/sub/file.ts' });
expect(override).toBeNull();
// /external/other path - NOT approved (sibling directory)
override = await tool.getApprovalOverride?.({ file_path: '/external/other/file.ts' });
expect(override).not.toBeNull();
});
});
// =====================================================================
// DIFFERENT EXTERNAL DIRECTORIES SCENARIOS
// =====================================================================
describe('Different External Directories Scenarios', () => {
it('should require separate approval for different external directories', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval,
});
const dir1Path = '/external/project1/file.ts';
const dir2Path = '/external/project2/file.ts';
// Both directories need approval
const override1 = await tool.getApprovalOverride?.({ file_path: dir1Path });
expect(override1).not.toBeNull();
const metadata1 = override1?.metadata as any;
expect(metadata1?.parentDir).toBe('/external/project1');
const override2 = await tool.getApprovalOverride?.({ file_path: dir2Path });
expect(override2).not.toBeNull();
const metadata2 = override2?.metadata as any;
expect(metadata2?.parentDir).toBe('/external/project2');
});
});
// =====================================================================
// MIXED OPERATIONS SCENARIOS
// =====================================================================
describe('Mixed Operations Scenarios', () => {
it('should share directory approval across different file operations', async () => {
const readTool = createReadFileTool({ fileSystemService, directoryApproval });
const writeTool = createWriteFileTool({ fileSystemService, directoryApproval });
const editTool = createEditFileTool({ fileSystemService, directoryApproval });
const externalDir = '/external/project';
// All operations need approval initially
expect(
await readTool.getApprovalOverride?.({ file_path: `${externalDir}/file1.ts` })
).not.toBeNull();
expect(
await writeTool.getApprovalOverride?.({
file_path: `${externalDir}/file2.ts`,
content: 'test',
})
).not.toBeNull();
expect(
await editTool.getApprovalOverride?.({
file_path: `${externalDir}/file3.ts`,
old_string: 'a',
new_string: 'b',
})
).not.toBeNull();
// Simulate session approval for the directory
isSessionApprovedMock.mockReturnValue(true);
// Now all operations should not need approval
expect(
await readTool.getApprovalOverride?.({ file_path: `${externalDir}/file1.ts` })
).toBeNull();
expect(
await writeTool.getApprovalOverride?.({
file_path: `${externalDir}/file2.ts`,
content: 'test',
})
).toBeNull();
expect(
await editTool.getApprovalOverride?.({
file_path: `${externalDir}/file3.ts`,
old_string: 'a',
new_string: 'b',
})
).toBeNull();
});
});
// =====================================================================
// NO DIRECTORY APPROVAL CALLBACKS SCENARIOS
// =====================================================================
describe('Without Directory Approval Callbacks', () => {
it('should work without directory approval callbacks (all paths need normal tool confirmation)', async () => {
const tool = createReadFileTool({
fileSystemService,
directoryApproval: undefined,
});
// External path without approval callbacks - no override (normal tool flow)
const override = await tool.getApprovalOverride?.({
file_path: '/external/project/file.ts',
});
// Without directoryApproval, isSessionApproved is never checked, so we fall through to
// returning a directory access request. Let me re-check the implementation...
// Actually looking at the code, when directoryApproval is undefined, isSessionApproved check
// is skipped (directoryApproval?.isSessionApproved), so it would still return the override.
// This is correct behavior - without approval callbacks, the override is still returned
// but onApprovalGranted won't do anything.
expect(override).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,266 @@
/**
* Edit File Tool Tests
*
* Tests for the edit_file tool including file modification detection.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { createEditFileTool } from './edit-file-tool.js';
import { FileSystemService } from './filesystem-service.js';
import { ToolErrorCode } from '@dexto/core';
import { DextoRuntimeError } from '@dexto/core';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('edit_file tool', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
let fileSystemService: FileSystemService;
beforeEach(async () => {
mockLogger = createMockLogger();
// Create temp directory for testing
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-edit-test-'));
tempDir = await fs.realpath(rawTempDir);
fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: false,
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
vi.clearAllMocks();
});
afterEach(async () => {
// Cleanup temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('File Modification Detection', () => {
it('should succeed when file is not modified between preview and execute', async () => {
const tool = createEditFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
const toolCallId = 'test-call-123';
const input = {
file_path: testFile,
old_string: 'world',
new_string: 'universe',
};
// Generate preview (stores hash)
const preview = await tool.generatePreview!(input, { toolCallId });
expect(preview).toBeDefined();
// Execute without modifying file (should succeed)
const result = (await tool.execute(input, { toolCallId })) as {
success: boolean;
path: string;
};
expect(result.success).toBe(true);
expect(result.path).toBe(testFile);
// Verify file was edited
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('hello universe');
});
it('should fail when file is modified between preview and execute', async () => {
const tool = createEditFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
const toolCallId = 'test-call-456';
const input = {
file_path: testFile,
old_string: 'world',
new_string: 'universe',
};
// Generate preview (stores hash)
const preview = await tool.generatePreview!(input, { toolCallId });
expect(preview).toBeDefined();
// Simulate user modifying the file externally (keep 'world' so edit would work without hash check)
await fs.writeFile(testFile, 'hello world - user added this');
// Execute should fail because file was modified
try {
await tool.execute(input, { toolCallId });
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
);
}
// Verify file was NOT modified by the tool (still has user's changes)
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('hello world - user added this');
});
it('should detect file modification with correct error code', async () => {
const tool = createEditFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
const toolCallId = 'test-call-789';
const input = {
file_path: testFile,
old_string: 'world',
new_string: 'universe',
};
// Generate preview (stores hash)
await tool.generatePreview!(input, { toolCallId });
// Simulate user modifying the file externally
await fs.writeFile(testFile, 'hello world modified');
// Execute should fail with FILE_MODIFIED_SINCE_PREVIEW error
try {
await tool.execute(input, { toolCallId });
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
);
expect((error as DextoRuntimeError).message).toContain(
'modified since the preview'
);
expect((error as DextoRuntimeError).message).toContain('read the file again');
}
});
it('should work without toolCallId (no modification check)', async () => {
const tool = createEditFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
const input = {
file_path: testFile,
old_string: 'world',
new_string: 'universe',
};
// Generate preview without toolCallId
await tool.generatePreview!(input, {});
// Modify file
await fs.writeFile(testFile, 'hello world changed');
// Execute without toolCallId - should NOT check for modifications
// (but will fail because old_string won't match the new content)
// This tests that the tool doesn't crash when toolCallId is missing
try {
await tool.execute(input, {});
} catch (error) {
// Expected to fail because 'world' is no longer in the file
// But it should NOT be FILE_MODIFIED_SINCE_PREVIEW error
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).not.toBe(
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
);
}
});
it('should clean up hash cache after successful execution', async () => {
const tool = createEditFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
const toolCallId = 'test-call-cleanup';
const input = {
file_path: testFile,
old_string: 'world',
new_string: 'universe',
};
// Generate preview
await tool.generatePreview!(input, { toolCallId });
// Execute successfully
await tool.execute(input, { toolCallId });
// Prepare for second edit
const input2 = {
file_path: testFile,
old_string: 'universe',
new_string: 'galaxy',
};
// Second execution with same toolCallId should work
// (hash should have been cleaned up, so no stale check)
await tool.generatePreview!(input2, { toolCallId });
const result = (await tool.execute(input2, { toolCallId })) as { success: boolean };
expect(result.success).toBe(true);
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('hello galaxy');
});
it('should clean up hash cache after failed execution', async () => {
const tool = createEditFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
const toolCallId = 'test-call-fail-cleanup';
const input = {
file_path: testFile,
old_string: 'world',
new_string: 'universe',
};
// Generate preview
await tool.generatePreview!(input, { toolCallId });
// Modify file to cause failure
await fs.writeFile(testFile, 'hello world modified');
// Execute should fail
try {
await tool.execute(input, { toolCallId });
} catch {
// Expected
}
// Reset file
await fs.writeFile(testFile, 'hello world');
// Next execution with same toolCallId should work
// (hash should have been cleaned up even after failure)
await tool.generatePreview!(input, { toolCallId });
const result = (await tool.execute(input, { toolCallId })) as { success: boolean };
expect(result.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,267 @@
/**
* Edit File Tool
*
* Internal tool for editing files by replacing text (requires approval)
*/
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { z } from 'zod';
import { createPatch } from 'diff';
import { InternalTool, ToolExecutionContext, ApprovalType } from '@dexto/core';
import type { DiffDisplayData, ApprovalRequestDetails, ApprovalResponse } from '@dexto/core';
import { ToolError } from '@dexto/core';
import { ToolErrorCode } from '@dexto/core';
import { DextoRuntimeError } from '@dexto/core';
import type { FileToolOptions } from './file-tool-types.js';
import { FileSystemErrorCode } from './error-codes.js';
/**
* Cache for content hashes between preview and execute phases.
* Keyed by toolCallId to ensure proper cleanup after execution.
* This prevents file corruption when user modifies file between preview and execute.
*/
const previewContentHashCache = new Map<string, string>();
/**
* Compute SHA-256 hash of content for change detection
*/
function computeContentHash(content: string): string {
return createHash('sha256').update(content, 'utf8').digest('hex');
}
const EditFileInputSchema = z
.object({
file_path: z.string().describe('Absolute path to the file to edit'),
old_string: z
.string()
.describe('Text to replace (must be unique unless replace_all is true)'),
new_string: z.string().describe('Replacement text'),
replace_all: z
.boolean()
.optional()
.default(false)
.describe('Replace all occurrences (default: false, requires unique match)'),
})
.strict();
type EditFileInput = z.input<typeof EditFileInputSchema>;
/**
* Generate diff preview without modifying the file
*/
function generateDiffPreview(
filePath: string,
originalContent: string,
newContent: string
): DiffDisplayData {
const unified = createPatch(filePath, originalContent, newContent, 'before', 'after', {
context: 3,
});
const additions = (unified.match(/^\+[^+]/gm) || []).length;
const deletions = (unified.match(/^-[^-]/gm) || []).length;
return {
type: 'diff',
unified,
filename: filePath,
additions,
deletions,
};
}
/**
* Create the edit_file internal tool with directory approval support
*/
export function createEditFileTool(options: FileToolOptions): InternalTool {
const { fileSystemService, directoryApproval } = options;
// Store parent directory for use in onApprovalGranted callback
let pendingApprovalParentDir: string | undefined;
return {
id: 'edit_file',
description:
'Edit a file by replacing text. By default, old_string must be unique in the file (will error if found multiple times). Set replace_all=true to replace all occurrences. Automatically creates backup before editing. Requires approval. Returns success status, path, number of changes made, and backup path.',
inputSchema: EditFileInputSchema,
/**
* Check if this edit operation needs directory access approval.
* Returns custom approval request if the file is outside allowed paths.
*/
getApprovalOverride: async (args: unknown): Promise<ApprovalRequestDetails | null> => {
const { file_path } = args as EditFileInput;
if (!file_path) return null;
// Check if path is within config-allowed paths (async for non-blocking symlink resolution)
const isAllowed = await fileSystemService.isPathWithinConfigAllowed(file_path);
if (isAllowed) {
return null; // Use normal tool confirmation
}
// Check if directory is already session-approved
if (directoryApproval?.isSessionApproved(file_path)) {
return null; // Already approved, use normal flow
}
// Need directory access approval
const absolutePath = path.resolve(file_path);
const parentDir = path.dirname(absolutePath);
pendingApprovalParentDir = parentDir;
return {
type: ApprovalType.DIRECTORY_ACCESS,
metadata: {
path: absolutePath,
parentDir,
operation: 'edit',
toolName: 'edit_file',
},
};
},
/**
* Handle approved directory access - remember the directory for session
*/
onApprovalGranted: (response: ApprovalResponse): void => {
if (!directoryApproval || !pendingApprovalParentDir) return;
// Check if user wants to remember the directory
// Use type assertion to access rememberDirectory since response.data is a union type
const data = response.data as { rememberDirectory?: boolean } | undefined;
const rememberDirectory = data?.rememberDirectory ?? false;
directoryApproval.addApproved(
pendingApprovalParentDir,
rememberDirectory ? 'session' : 'once'
);
// Clear pending state
pendingApprovalParentDir = undefined;
},
/**
* Generate preview for approval UI - shows diff without modifying file
* Throws ToolError.validationFailed() for validation errors (file not found, string not found)
* Stores content hash for change detection in execute phase.
*/
generatePreview: async (input: unknown, context?: ToolExecutionContext) => {
const { file_path, old_string, new_string, replace_all } = input as EditFileInput;
try {
// Read current file content
const originalFile = await fileSystemService.readFile(file_path);
const originalContent = originalFile.content;
// Store content hash for change detection in execute phase
if (context?.toolCallId) {
previewContentHashCache.set(
context.toolCallId,
computeContentHash(originalContent)
);
}
// Validate uniqueness constraint when replace_all is false
if (!replace_all) {
const occurrences = originalContent.split(old_string).length - 1;
if (occurrences > 1) {
throw ToolError.validationFailed(
'edit_file',
`String found ${occurrences} times in file. Set replace_all=true to replace all, or provide more context to make old_string unique.`,
{ file_path, occurrences }
);
}
}
// Compute what the new content would be
const newContent = replace_all
? originalContent.split(old_string).join(new_string)
: originalContent.replace(old_string, new_string);
// If no change, old_string was not found - throw validation error
if (originalContent === newContent) {
throw ToolError.validationFailed(
'edit_file',
`String not found in file: "${old_string.slice(0, 50)}${old_string.length > 50 ? '...' : ''}"`,
{ file_path, old_string_preview: old_string.slice(0, 100) }
);
}
return generateDiffPreview(file_path, originalContent, newContent);
} catch (error) {
// Re-throw validation errors as-is
if (
error instanceof DextoRuntimeError &&
error.code === ToolErrorCode.VALIDATION_FAILED
) {
throw error;
}
// Convert filesystem errors (file not found, etc.) to validation errors
if (error instanceof DextoRuntimeError) {
throw ToolError.validationFailed('edit_file', error.message, {
file_path,
originalErrorCode: error.code,
});
}
// Unexpected errors - return null to allow approval to proceed
return null;
}
},
execute: async (input: unknown, context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { file_path, old_string, new_string, replace_all } = input as EditFileInput;
// Check if file was modified since preview (safety check)
// This prevents corrupting user edits made between preview approval and execution
if (context?.toolCallId && previewContentHashCache.has(context.toolCallId)) {
const expectedHash = previewContentHashCache.get(context.toolCallId)!;
previewContentHashCache.delete(context.toolCallId); // Clean up regardless of outcome
// Read current content to verify it hasn't changed
let currentContent: string;
try {
const currentFile = await fileSystemService.readFile(file_path);
currentContent = currentFile.content;
} catch (error) {
// File was deleted between preview and execute - treat as modified
if (
error instanceof DextoRuntimeError &&
error.code === FileSystemErrorCode.FILE_NOT_FOUND
) {
throw ToolError.fileModifiedSincePreview('edit_file', file_path);
}
throw error;
}
const currentHash = computeContentHash(currentContent);
if (expectedHash !== currentHash) {
throw ToolError.fileModifiedSincePreview('edit_file', file_path);
}
}
// Edit file using FileSystemService
// Backup behavior is controlled by config.enableBackups (default: false)
// editFile returns originalContent and newContent, eliminating extra file reads
const result = await fileSystemService.editFile(file_path, {
oldString: old_string,
newString: new_string,
replaceAll: replace_all,
});
// Generate display data using content returned from editFile
const _display = generateDiffPreview(
file_path,
result.originalContent,
result.newContent
);
return {
success: result.success,
path: result.path,
changes_count: result.changesCount,
...(result.backupPath && { backup_path: result.backupPath }),
_display,
};
},
};
}

View File

@@ -0,0 +1,44 @@
/**
* FileSystem Service Error Codes
*
* Standardized error codes for file system operations
*/
export enum FileSystemErrorCode {
// File not found errors
FILE_NOT_FOUND = 'FILESYSTEM_FILE_NOT_FOUND',
DIRECTORY_NOT_FOUND = 'FILESYSTEM_DIRECTORY_NOT_FOUND',
// Permission errors
PERMISSION_DENIED = 'FILESYSTEM_PERMISSION_DENIED',
PATH_NOT_ALLOWED = 'FILESYSTEM_PATH_NOT_ALLOWED',
PATH_BLOCKED = 'FILESYSTEM_PATH_BLOCKED',
// Validation errors
INVALID_PATH = 'FILESYSTEM_INVALID_PATH',
PATH_TRAVERSAL_DETECTED = 'FILESYSTEM_PATH_TRAVERSAL_DETECTED',
INVALID_FILE_EXTENSION = 'FILESYSTEM_INVALID_FILE_EXTENSION',
INVALID_ENCODING = 'FILESYSTEM_INVALID_ENCODING',
// Size errors
FILE_TOO_LARGE = 'FILESYSTEM_FILE_TOO_LARGE',
TOO_MANY_RESULTS = 'FILESYSTEM_TOO_MANY_RESULTS',
// Operation errors
READ_FAILED = 'FILESYSTEM_READ_FAILED',
WRITE_FAILED = 'FILESYSTEM_WRITE_FAILED',
BACKUP_FAILED = 'FILESYSTEM_BACKUP_FAILED',
EDIT_FAILED = 'FILESYSTEM_EDIT_FAILED',
STRING_NOT_UNIQUE = 'FILESYSTEM_STRING_NOT_UNIQUE',
STRING_NOT_FOUND = 'FILESYSTEM_STRING_NOT_FOUND',
// Search errors
GLOB_FAILED = 'FILESYSTEM_GLOB_FAILED',
SEARCH_FAILED = 'FILESYSTEM_SEARCH_FAILED',
INVALID_PATTERN = 'FILESYSTEM_INVALID_PATTERN',
REGEX_TIMEOUT = 'FILESYSTEM_REGEX_TIMEOUT',
// Configuration errors
INVALID_CONFIG = 'FILESYSTEM_INVALID_CONFIG',
SERVICE_NOT_INITIALIZED = 'FILESYSTEM_SERVICE_NOT_INITIALIZED',
}

View File

@@ -0,0 +1,324 @@
/**
* FileSystem Service Errors
*
* Error classes for file system operations
*/
import { DextoRuntimeError, ErrorType } from '@dexto/core';
/** Error scope for filesystem operations */
const FILESYSTEM_SCOPE = 'filesystem';
import { FileSystemErrorCode } from './error-codes.js';
export interface FileSystemErrorContext {
path?: string;
pattern?: string;
size?: number;
maxSize?: number;
encoding?: string;
operation?: string;
}
/**
* Factory class for creating FileSystem-related errors
*/
export class FileSystemError {
private constructor() {
// Private constructor prevents instantiation
}
/**
* File not found error
*/
static fileNotFound(path: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.FILE_NOT_FOUND,
FILESYSTEM_SCOPE,
ErrorType.NOT_FOUND,
`File not found: ${path}`,
{ path }
);
}
/**
* Directory not found error
*/
static directoryNotFound(path: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.DIRECTORY_NOT_FOUND,
FILESYSTEM_SCOPE,
ErrorType.NOT_FOUND,
`Directory not found: ${path}`,
{ path }
);
}
/**
* Permission denied error
*/
static permissionDenied(path: string, operation: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.PERMISSION_DENIED,
FILESYSTEM_SCOPE,
ErrorType.FORBIDDEN,
`Permission denied: cannot ${operation} ${path}`,
{ path, operation }
);
}
/**
* Path not allowed error
*/
static pathNotAllowed(path: string, allowedPaths: string[]): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.PATH_NOT_ALLOWED,
FILESYSTEM_SCOPE,
ErrorType.USER,
`Path not allowed: ${path}. Must be within allowed paths: ${allowedPaths.join(', ')}`,
{ path, allowedPaths },
'Ensure the path is within the configured allowed paths'
);
}
/**
* Path blocked error
*/
static pathBlocked(path: string, reason: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.PATH_BLOCKED,
FILESYSTEM_SCOPE,
ErrorType.FORBIDDEN,
`Path is blocked: ${path}. Reason: ${reason}`,
{ path, reason }
);
}
/**
* Invalid path error
*/
static invalidPath(path: string, reason: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.INVALID_PATH,
FILESYSTEM_SCOPE,
ErrorType.USER,
`Invalid path: ${path}. ${reason}`,
{ path, reason }
);
}
/**
* Path traversal detected
*/
static pathTraversal(path: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.PATH_TRAVERSAL_DETECTED,
FILESYSTEM_SCOPE,
ErrorType.FORBIDDEN,
`Path traversal detected in: ${path}`,
{ path }
);
}
/**
* Invalid file extension error
*/
static invalidExtension(path: string, blockedExtensions: string[]): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.INVALID_FILE_EXTENSION,
FILESYSTEM_SCOPE,
ErrorType.USER,
`Invalid file extension: ${path}. Blocked extensions: ${blockedExtensions.join(', ')}`,
{ path, blockedExtensions }
);
}
/**
* File too large error
*/
static fileTooLarge(path: string, size: number, maxSize: number): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.FILE_TOO_LARGE,
FILESYSTEM_SCOPE,
ErrorType.USER,
`File too large: ${path} (${size} bytes). Maximum allowed: ${maxSize} bytes`,
{ path, size, maxSize }
);
}
/**
* Too many results error
*/
static tooManyResults(operation: string, count: number, maxResults: number): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.TOO_MANY_RESULTS,
FILESYSTEM_SCOPE,
ErrorType.USER,
`Too many results from ${operation}: ${count}. Maximum allowed: ${maxResults}`,
{ operation, count, maxResults },
'Narrow your search pattern or increase maxResults limit'
);
}
/**
* Read operation failed
*/
static readFailed(path: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.READ_FAILED,
FILESYSTEM_SCOPE,
ErrorType.SYSTEM,
`Failed to read file: ${path}. ${cause}`,
{ path, cause }
);
}
/**
* Write operation failed
*/
static writeFailed(path: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.WRITE_FAILED,
FILESYSTEM_SCOPE,
ErrorType.SYSTEM,
`Failed to write file: ${path}. ${cause}`,
{ path, cause }
);
}
/**
* Backup creation failed
*/
static backupFailed(path: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.BACKUP_FAILED,
FILESYSTEM_SCOPE,
ErrorType.SYSTEM,
`Failed to create backup for: ${path}. ${cause}`,
{ path, cause }
);
}
/**
* Edit operation failed
*/
static editFailed(path: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.EDIT_FAILED,
FILESYSTEM_SCOPE,
ErrorType.SYSTEM,
`Failed to edit file: ${path}. ${cause}`,
{ path, cause }
);
}
/**
* String not unique error
*/
static stringNotUnique(
path: string,
searchString: string,
occurrences: number
): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.STRING_NOT_UNIQUE,
FILESYSTEM_SCOPE,
ErrorType.USER,
`String is not unique in ${path}: "${searchString}" found ${occurrences} times. Use replaceAll=true or provide a more specific string.`,
{ path, searchString, occurrences },
'Use replaceAll option or provide more context in the search string'
);
}
/**
* String not found error
*/
static stringNotFound(path: string, searchString: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.STRING_NOT_FOUND,
FILESYSTEM_SCOPE,
ErrorType.USER,
`String not found in ${path}: "${searchString}"`,
{ path, searchString }
);
}
/**
* Glob operation failed
*/
static globFailed(pattern: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.GLOB_FAILED,
FILESYSTEM_SCOPE,
ErrorType.SYSTEM,
`Glob operation failed for pattern: ${pattern}. ${cause}`,
{ pattern, cause }
);
}
/**
* Search operation failed
*/
static searchFailed(pattern: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.SEARCH_FAILED,
FILESYSTEM_SCOPE,
ErrorType.SYSTEM,
`Search operation failed for pattern: ${pattern}. ${cause}`,
{ pattern, cause }
);
}
/**
* Invalid pattern error
*/
static invalidPattern(pattern: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.INVALID_PATTERN,
FILESYSTEM_SCOPE,
ErrorType.USER,
`Invalid pattern: ${pattern}. ${cause}`,
{ pattern, cause }
);
}
/**
* Regex timeout error
*/
static regexTimeout(pattern: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.REGEX_TIMEOUT,
FILESYSTEM_SCOPE,
ErrorType.TIMEOUT,
`Regex operation timed out for pattern: ${pattern}`,
{ pattern },
'Simplify your regex pattern or increase timeout'
);
}
/**
* Invalid configuration error
*/
static invalidConfig(reason: string): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.INVALID_CONFIG,
FILESYSTEM_SCOPE,
ErrorType.USER,
`Invalid FileSystem configuration: ${reason}`,
{ reason }
);
}
/**
* Service not initialized error
*/
static notInitialized(): DextoRuntimeError {
return new DextoRuntimeError(
FileSystemErrorCode.SERVICE_NOT_INITIALIZED,
FILESYSTEM_SCOPE,
ErrorType.SYSTEM,
'FileSystemService has not been initialized',
{},
'Initialize the FileSystemService before using it'
);
}
}

View File

@@ -0,0 +1,45 @@
/**
* File Tool Types
*
* Types shared between file tools for directory approval support.
*/
import type { FileSystemService } from './filesystem-service.js';
/**
* Callbacks for directory access approval.
* Allows file tools to check and request approval for accessing paths
* outside the configured working directory.
*/
export interface DirectoryApprovalCallbacks {
/**
* Check if a path is within any session-approved directory.
* Used to determine if directory approval prompt is needed.
* @param filePath The file path to check (absolute or relative)
* @returns true if path is in a session-approved directory
*/
isSessionApproved: (filePath: string) => boolean;
/**
* Add a directory to the approved list for this session.
* Called after user approves directory access.
* @param directory Absolute path to the directory to approve
* @param type 'session' (remembered) or 'once' (single use)
*/
addApproved: (directory: string, type: 'session' | 'once') => void;
}
/**
* Options for creating file tools with directory approval support
*/
export interface FileToolOptions {
/** FileSystemService instance for file operations */
fileSystemService: FileSystemService;
/**
* Optional callbacks for directory approval.
* If provided, file tools can request approval for accessing paths
* outside the configured working directory.
*/
directoryApproval?: DirectoryApprovalCallbacks | undefined;
}

View File

@@ -0,0 +1,277 @@
/**
* FileSystemService Tests
*
* Tests for the core filesystem service including backup behavior.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { FileSystemService } from './filesystem-service.js';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('FileSystemService', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
beforeEach(async () => {
mockLogger = createMockLogger();
// Create temp directory for testing
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-fs-test-'));
tempDir = await fs.realpath(rawTempDir);
vi.clearAllMocks();
});
afterEach(async () => {
// Cleanup temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('Backup Behavior', () => {
describe('writeFile', () => {
it('should NOT create backup when enableBackups is false (default)', async () => {
const fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: false,
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
// Create initial file
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'original content');
// Write new content (should NOT create backup)
const result = await fileSystemService.writeFile(testFile, 'new content');
expect(result.success).toBe(true);
expect(result.backupPath).toBeUndefined();
// Verify backup directory doesn't exist or is empty
const backupDir = path.join(tempDir, '.dexto-backups');
try {
const files = await fs.readdir(backupDir);
expect(files.length).toBe(0);
} catch {
// Backup dir doesn't exist, which is expected
}
});
it('should create backup when enableBackups is true', async () => {
const fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: true,
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
// Create initial file
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'original content');
// Write new content (should create backup)
const result = await fileSystemService.writeFile(testFile, 'new content');
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
expect(result.backupPath).toContain('.dexto');
expect(result.backupPath).toContain('backup');
// Verify backup file exists and contains original content
const backupContent = await fs.readFile(result.backupPath!, 'utf-8');
expect(backupContent).toBe('original content');
// Verify new content was written
const newContent = await fs.readFile(testFile, 'utf-8');
expect(newContent).toBe('new content');
});
it('should NOT create backup for new files even when enableBackups is true', async () => {
const fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: true,
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
// Write to a new file (no backup needed since file doesn't exist)
const testFile = path.join(tempDir, 'new-file.txt');
const result = await fileSystemService.writeFile(testFile, 'content', {
createDirs: true,
});
expect(result.success).toBe(true);
expect(result.backupPath).toBeUndefined();
});
it('should respect per-call backup option over config', async () => {
const fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: false, // Config says no backups
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
// Create initial file
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'original content');
// Write with explicit backup: true (should override config)
const result = await fileSystemService.writeFile(testFile, 'new content', {
backup: true,
});
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
});
});
describe('editFile', () => {
it('should NOT create backup when enableBackups is false (default)', async () => {
const fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: false,
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
// Create initial file
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
// Edit file (should NOT create backup)
const result = await fileSystemService.editFile(testFile, {
oldString: 'world',
newString: 'universe',
});
expect(result.success).toBe(true);
expect(result.backupPath).toBeUndefined();
// Verify content was changed
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('hello universe');
});
it('should create backup when enableBackups is true', async () => {
const fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: true,
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
// Create initial file
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
// Edit file (should create backup)
const result = await fileSystemService.editFile(testFile, {
oldString: 'world',
newString: 'universe',
});
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
// Verify backup contains original content
const backupContent = await fs.readFile(result.backupPath!, 'utf-8');
expect(backupContent).toBe('hello world');
// Verify new content
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('hello universe');
});
it('should respect per-call backup option over config', async () => {
const fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: false, // Config says no backups
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
// Create initial file
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'hello world');
// Edit with explicit backup: true (should override config)
const result = await fileSystemService.editFile(
testFile,
{
oldString: 'world',
newString: 'universe',
},
{ backup: true }
);
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
});
});
});
});

View File

@@ -0,0 +1,666 @@
/**
* FileSystem Service
*
* Secure file system operations for Dexto internal tools
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { glob } from 'glob';
import safeRegex from 'safe-regex';
import { getDextoPath, IDextoLogger, DextoLogComponent } from '@dexto/core';
import {
FileSystemConfig,
FileContent,
ReadFileOptions,
GlobOptions,
GlobResult,
GrepOptions,
SearchResult,
SearchMatch,
WriteFileOptions,
WriteResult,
EditFileOptions,
EditResult,
EditOperation,
FileMetadata,
BufferEncoding,
} from './types.js';
import { PathValidator } from './path-validator.js';
import { FileSystemError } from './errors.js';
const DEFAULT_ENCODING: BufferEncoding = 'utf-8';
const DEFAULT_MAX_RESULTS = 1000;
const DEFAULT_MAX_SEARCH_RESULTS = 100;
/**
* FileSystemService - Handles all file system operations with security checks
*
* This service receives fully-validated configuration from the FileSystem Tools Provider.
* All defaults have been applied by the provider's schema, so the service trusts the config
* and uses it as-is without any fallback logic.
*
* TODO: Add tests for this class
* TODO: instantiate only when internal file tools are enabled to avoid file dependencies which won't work in serverless
*/
export class FileSystemService {
private config: FileSystemConfig;
private pathValidator: PathValidator;
private initialized: boolean = false;
private initPromise: Promise<void> | null = null;
private logger: IDextoLogger;
/**
* Create a new FileSystemService with validated configuration.
*
* @param config - Fully-validated configuration from provider schema.
* All required fields have values, defaults already applied.
* @param logger - Logger instance for this service
*/
constructor(config: FileSystemConfig, logger: IDextoLogger) {
// Config is already fully validated with defaults applied - just use it
this.config = config;
this.logger = logger.createChild(DextoLogComponent.FILESYSTEM);
this.pathValidator = new PathValidator(this.config, this.logger);
}
/**
* Get backup directory path (context-aware with optional override)
* TODO: Migrate to explicit configuration via CLI enrichment layer (per-agent paths)
*/
private getBackupDir(): string {
// Use custom path if provided (absolute), otherwise use context-aware default
return this.config.backupPath || getDextoPath('backups');
}
/**
* Get the effective working directory for file operations.
* Falls back to process.cwd() if not configured.
*/
getWorkingDirectory(): string {
return this.config.workingDirectory || process.cwd();
}
/**
* Initialize the service.
* Safe to call multiple times - subsequent calls return the same promise.
*/
initialize(): Promise<void> {
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = this.doInitialize();
return this.initPromise;
}
/**
* Internal initialization logic.
*/
private async doInitialize(): Promise<void> {
if (this.initialized) {
this.logger.debug('FileSystemService already initialized');
return;
}
// Create backup directory if backups are enabled
if (this.config.enableBackups) {
try {
const backupDir = this.getBackupDir();
await fs.mkdir(backupDir, { recursive: true });
this.logger.debug(`Backup directory created/verified: ${backupDir}`);
} catch (error) {
this.logger.warn(
`Failed to create backup directory: ${error instanceof Error ? error.message : String(error)}`
);
}
}
this.initialized = true;
this.logger.info('FileSystemService initialized successfully');
}
/**
* Ensure the service is initialized before use.
* Tools should call this at the start of their execute methods.
* Safe to call multiple times - will await the same initialization promise.
*/
async ensureInitialized(): Promise<void> {
if (this.initialized) {
return;
}
await this.initialize();
}
/**
* Set a callback to check if a path is in an approved directory.
* This allows PathValidator to consult ApprovalManager without a direct dependency.
*
* @param checker Function that returns true if path is in an approved directory
*/
setDirectoryApprovalChecker(checker: (filePath: string) => boolean): void {
this.pathValidator.setDirectoryApprovalChecker(checker);
}
/**
* Check if a file path is within the configured allowed paths (config only).
* This is used by file tools to determine if directory approval is needed.
*
* @param filePath The file path to check (can be relative or absolute)
* @returns true if the path is within config-allowed paths, false otherwise
*/
async isPathWithinConfigAllowed(filePath: string): Promise<boolean> {
return this.pathValidator.isPathWithinAllowed(filePath);
}
/**
* Read a file with validation and size limits
*/
async readFile(filePath: string, options: ReadFileOptions = {}): Promise<FileContent> {
await this.ensureInitialized();
// Validate path (async for non-blocking symlink resolution)
const validation = await this.pathValidator.validatePath(filePath);
if (!validation.isValid || !validation.normalizedPath) {
throw FileSystemError.invalidPath(filePath, validation.error || 'Unknown error');
}
const normalizedPath = validation.normalizedPath;
// Check if file exists
try {
const stats = await fs.stat(normalizedPath);
if (!stats.isFile()) {
throw FileSystemError.invalidPath(normalizedPath, 'Path is not a file');
}
// Check file size
if (stats.size > this.config.maxFileSize) {
throw FileSystemError.fileTooLarge(
normalizedPath,
stats.size,
this.config.maxFileSize
);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw FileSystemError.fileNotFound(normalizedPath);
}
if ((error as NodeJS.ErrnoException).code === 'EACCES') {
throw FileSystemError.permissionDenied(normalizedPath, 'read');
}
throw FileSystemError.readFailed(
normalizedPath,
error instanceof Error ? error.message : String(error)
);
}
// Read file
try {
const encoding = options.encoding || DEFAULT_ENCODING;
const content = await fs.readFile(normalizedPath, encoding);
const lines = content.split('\n');
// Handle offset (1-based per types) and limit
const limit = options.limit;
const offset1 = options.offset; // 1-based if provided
let selectedLines: string[];
let truncated = false;
if ((offset1 && offset1 > 0) || limit !== undefined) {
const start = offset1 && offset1 > 0 ? Math.max(0, offset1 - 1) : 0;
const end = limit !== undefined ? start + limit : lines.length;
selectedLines = lines.slice(start, end);
truncated = end < lines.length;
} else {
selectedLines = lines;
}
return {
content: selectedLines.join('\n'),
lines: selectedLines.length,
encoding,
truncated,
size: Buffer.byteLength(content, encoding),
};
} catch (error) {
throw FileSystemError.readFailed(
normalizedPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Find files matching a glob pattern
*/
async globFiles(pattern: string, options: GlobOptions = {}): Promise<GlobResult> {
await this.ensureInitialized();
const cwd: string = options.cwd || this.config.workingDirectory || process.cwd();
const maxResults = options.maxResults || DEFAULT_MAX_RESULTS;
try {
// Execute glob search
const files = await glob(pattern, {
cwd,
absolute: true,
nodir: true, // Only files
follow: false, // Don't follow symlinks
});
// Validate each path and collect metadata
const validFiles: FileMetadata[] = [];
for (const file of files) {
// Validate path (async for non-blocking symlink resolution)
const validation = await this.pathValidator.validatePath(file);
if (!validation.isValid || !validation.normalizedPath) {
this.logger.debug(`Skipping invalid path: ${file}`);
continue;
}
// Get metadata if requested
if (options.includeMetadata !== false) {
try {
const stats = await fs.stat(validation.normalizedPath);
validFiles.push({
path: validation.normalizedPath,
size: stats.size,
modified: stats.mtime,
isDirectory: stats.isDirectory(),
});
} catch (error) {
this.logger.debug(
`Failed to stat file ${file}: ${error instanceof Error ? error.message : String(error)}`
);
}
} else {
validFiles.push({
path: validation.normalizedPath,
size: 0,
modified: new Date(),
isDirectory: false,
});
}
// Check if we've reached the limit
if (validFiles.length >= maxResults) {
break;
}
}
const limited = validFiles.length >= maxResults;
return {
files: validFiles,
truncated: limited,
totalFound: validFiles.length,
};
} catch (error) {
throw FileSystemError.globFailed(
pattern,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Search for content in files (grep-like functionality)
*/
async searchContent(pattern: string, options: GrepOptions = {}): Promise<SearchResult> {
await this.ensureInitialized();
const searchPath: string = options.path || this.config.workingDirectory || process.cwd();
const globPattern = options.glob || '**/*';
const maxResults = options.maxResults || DEFAULT_MAX_SEARCH_RESULTS;
const contextLines = options.contextLines || 0;
try {
// Validate regex pattern for ReDoS safety before creating RegExp
// See: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
if (!safeRegex(pattern)) {
throw FileSystemError.invalidPattern(
pattern,
'Pattern may cause catastrophic backtracking (ReDoS). Please simplify the regex.'
);
}
const flags = options.caseInsensitive ? 'i' : '';
const regex = new RegExp(pattern, flags);
// Find files to search
const globResult = await this.globFiles(globPattern, {
cwd: searchPath,
maxResults: 10000, // Search more files, but limit results
});
const matches: SearchMatch[] = [];
let filesSearched = 0;
for (const fileInfo of globResult.files) {
try {
// Read file
const fileContent = await this.readFile(fileInfo.path);
const lines = fileContent.content.split('\n');
filesSearched++;
// Search for pattern in each line
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!; // Safe: we're iterating within bounds
if (regex.test(line)) {
// Collect context lines if requested
let context: { before: string[]; after: string[] } | undefined;
if (contextLines > 0) {
const before: string[] = [];
const after: string[] = [];
for (let j = Math.max(0, i - contextLines); j < i; j++) {
before.push(lines[j]!); // Safe: j is within bounds
}
for (
let j = i + 1;
j < Math.min(lines.length, i + contextLines + 1);
j++
) {
after.push(lines[j]!); // Safe: j is within bounds
}
context = { before, after };
}
matches.push({
file: fileInfo.path,
lineNumber: i + 1, // 1-based line numbers
line,
...(context !== undefined && { context }),
});
// Check if we've reached max results
if (matches.length >= maxResults) {
return {
matches,
totalMatches: matches.length,
truncated: true,
filesSearched,
};
}
}
}
} catch (error) {
// Skip files that can't be read
this.logger.debug(
`Skipping file ${fileInfo.path}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
return {
matches,
totalMatches: matches.length,
truncated: false,
filesSearched,
};
} catch (error) {
if (error instanceof Error && error.message.includes('Invalid regular expression')) {
throw FileSystemError.invalidPattern(pattern, 'Invalid regular expression syntax');
}
throw FileSystemError.searchFailed(
pattern,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Write content to a file
*/
async writeFile(
filePath: string,
content: string,
options: WriteFileOptions = {}
): Promise<WriteResult> {
await this.ensureInitialized();
// Validate path (async for non-blocking symlink resolution)
const validation = await this.pathValidator.validatePath(filePath);
if (!validation.isValid || !validation.normalizedPath) {
throw FileSystemError.invalidPath(filePath, validation.error || 'Unknown error');
}
const normalizedPath = validation.normalizedPath;
const encoding = options.encoding || DEFAULT_ENCODING;
// Check if file exists for backup
let backupPath: string | undefined;
let fileExists = false;
try {
await fs.access(normalizedPath);
fileExists = true;
} catch {
// File doesn't exist, which is fine
}
// Create backup if file exists and backups are enabled
if (fileExists && (options.backup ?? this.config.enableBackups)) {
backupPath = await this.createBackup(normalizedPath);
}
try {
// Create parent directories if needed
if (options.createDirs) {
const dir = path.dirname(normalizedPath);
await fs.mkdir(dir, { recursive: true });
}
// Write file
await fs.writeFile(normalizedPath, content, encoding);
const bytesWritten = Buffer.byteLength(content, encoding);
this.logger.debug(`File written: ${normalizedPath} (${bytesWritten} bytes)`);
return {
success: true,
path: normalizedPath,
bytesWritten,
backupPath,
};
} catch (error) {
throw FileSystemError.writeFailed(
normalizedPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Edit a file by replacing text
*/
async editFile(
filePath: string,
operation: EditOperation,
options: EditFileOptions = {}
): Promise<EditResult> {
await this.ensureInitialized();
// Validate path (async for non-blocking symlink resolution)
const validation = await this.pathValidator.validatePath(filePath);
if (!validation.isValid || !validation.normalizedPath) {
throw FileSystemError.invalidPath(filePath, validation.error || 'Unknown error');
}
const normalizedPath = validation.normalizedPath;
// Read current file content
const fileContent = await this.readFile(normalizedPath);
const originalContent = fileContent.content;
// Count occurrences of old string
const occurrences = (
originalContent.match(
new RegExp(operation.oldString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
) || []
).length;
if (occurrences === 0) {
throw FileSystemError.stringNotFound(normalizedPath, operation.oldString);
}
if (!operation.replaceAll && occurrences > 1) {
throw FileSystemError.stringNotUnique(normalizedPath, operation.oldString, occurrences);
}
// Create backup if enabled
let backupPath: string | undefined;
if (options.backup ?? this.config.enableBackups) {
backupPath = await this.createBackup(normalizedPath);
}
try {
// Perform replacement
let newContent: string;
if (operation.replaceAll) {
newContent = originalContent.replace(
new RegExp(operation.oldString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
operation.newString
);
} else {
newContent = originalContent.replace(operation.oldString, operation.newString);
}
// Write updated content
await fs.writeFile(normalizedPath, newContent, options.encoding || DEFAULT_ENCODING);
this.logger.debug(`File edited: ${normalizedPath} (${occurrences} replacements)`);
return {
success: true,
path: normalizedPath,
changesCount: occurrences,
backupPath,
originalContent,
newContent,
};
} catch (error) {
throw FileSystemError.editFailed(
normalizedPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Create a backup of a file
*/
private async createBackup(filePath: string): Promise<string> {
const backupDir = this.getBackupDir();
// Generate backup filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const basename = path.basename(filePath);
const backupFilename = `${basename}.${timestamp}.backup`;
const backupPath = path.join(backupDir, backupFilename);
try {
await fs.mkdir(backupDir, { recursive: true });
await fs.copyFile(filePath, backupPath);
this.logger.debug(`Backup created: ${backupPath}`);
// Clean up old backups after creating new one
await this.cleanupOldBackups();
return backupPath;
} catch (error) {
throw FileSystemError.backupFailed(
filePath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Clean up old backup files based on retention policy
*/
async cleanupOldBackups(): Promise<number> {
if (!this.config.enableBackups) {
return 0;
}
let backupDir: string;
try {
backupDir = this.getBackupDir();
} catch (error) {
this.logger.warn(
`Failed to resolve backup directory: ${error instanceof Error ? error.message : String(error)}`
);
return 0;
}
try {
// Check if backup directory exists
await fs.access(backupDir);
} catch {
// Directory doesn't exist, nothing to clean
return 0;
}
const cutoffDate = new Date(
Date.now() - this.config.backupRetentionDays * 24 * 60 * 60 * 1000
);
let deletedCount = 0;
try {
const files = await fs.readdir(backupDir);
const backupFiles = files.filter((file) => file.endsWith('.backup'));
for (const file of backupFiles) {
const filePath = path.join(backupDir, file);
try {
const stats = await fs.stat(filePath);
if (stats.mtime < cutoffDate) {
await fs.unlink(filePath);
deletedCount++;
this.logger.debug(`Cleaned up old backup: ${file}`);
}
} catch (error) {
this.logger.warn(
`Failed to process backup file ${file}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
if (deletedCount > 0) {
this.logger.info(`Backup cleanup: removed ${deletedCount} old backup files`);
}
return deletedCount;
} catch (error) {
this.logger.warn(
`Failed to cleanup backup directory: ${error instanceof Error ? error.message : String(error)}`
);
return 0;
}
}
/**
* Get service configuration
*/
getConfig(): Readonly<FileSystemConfig> {
return { ...this.config };
}
/**
* Check if a path is allowed (async for non-blocking symlink resolution)
*/
async isPathAllowed(filePath: string): Promise<boolean> {
const validation = await this.pathValidator.validatePath(filePath);
return validation.isValid;
}
}

View File

@@ -0,0 +1,140 @@
/**
* Glob Files Tool
*
* Internal tool for finding files using glob patterns
*/
import * as path from 'node:path';
import { z } from 'zod';
import { InternalTool, ToolExecutionContext, ApprovalType } from '@dexto/core';
import type { SearchDisplayData, ApprovalRequestDetails, ApprovalResponse } from '@dexto/core';
import type { FileToolOptions } from './file-tool-types.js';
const GlobFilesInputSchema = z
.object({
pattern: z
.string()
.describe('Glob pattern to match files (e.g., "**/*.ts", "src/**/*.js")'),
path: z
.string()
.optional()
.describe('Base directory to search from (defaults to working directory)'),
max_results: z
.number()
.int()
.positive()
.optional()
.default(1000)
.describe('Maximum number of results to return (default: 1000)'),
})
.strict();
type GlobFilesInput = z.input<typeof GlobFilesInputSchema>;
/**
* Create the glob_files internal tool with directory approval support
*/
export function createGlobFilesTool(options: FileToolOptions): InternalTool {
const { fileSystemService, directoryApproval } = options;
// Store search directory for use in onApprovalGranted callback
let pendingApprovalSearchDir: string | undefined;
return {
id: 'glob_files',
description:
'Find files matching a glob pattern. Supports standard glob syntax like **/*.js for recursive matches, *.ts for files in current directory, and src/**/*.tsx for nested paths. Returns array of file paths with metadata (size, modified date). Results are limited to allowed paths only.',
inputSchema: GlobFilesInputSchema,
/**
* Check if this glob operation needs directory access approval.
* Returns custom approval request if the search directory is outside allowed paths.
*/
getApprovalOverride: async (args: unknown): Promise<ApprovalRequestDetails | null> => {
const { path: searchPath } = args as GlobFilesInput;
// Resolve the search directory using the same base the service uses
// This ensures approval decisions align with actual execution context
const baseDir = fileSystemService.getWorkingDirectory();
const searchDir = path.resolve(baseDir, searchPath || '.');
// Check if path is within config-allowed paths
const isAllowed = await fileSystemService.isPathWithinConfigAllowed(searchDir);
if (isAllowed) {
return null; // Use normal tool confirmation
}
// Check if directory is already session-approved
if (directoryApproval?.isSessionApproved(searchDir)) {
return null; // Already approved, use normal flow
}
// Need directory access approval
pendingApprovalSearchDir = searchDir;
return {
type: ApprovalType.DIRECTORY_ACCESS,
metadata: {
path: searchDir,
parentDir: searchDir,
operation: 'search',
toolName: 'glob_files',
},
};
},
/**
* Handle approved directory access - remember the directory for session
*/
onApprovalGranted: (response: ApprovalResponse): void => {
if (!directoryApproval || !pendingApprovalSearchDir) return;
// Check if user wants to remember the directory
const data = response.data as { rememberDirectory?: boolean } | undefined;
const rememberDirectory = data?.rememberDirectory ?? false;
directoryApproval.addApproved(
pendingApprovalSearchDir,
rememberDirectory ? 'session' : 'once'
);
// Clear pending state
pendingApprovalSearchDir = undefined;
},
execute: async (input: unknown, _context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { pattern, path, max_results } = input as GlobFilesInput;
// Search for files using FileSystemService
const result = await fileSystemService.globFiles(pattern, {
cwd: path,
maxResults: max_results,
includeMetadata: true,
});
// Build display data (reuse SearchDisplayData for file list)
const _display: SearchDisplayData = {
type: 'search',
pattern,
matches: result.files.map((file) => ({
file: file.path,
line: 0, // No line number for glob
content: file.path,
})),
totalMatches: result.totalFound,
truncated: result.truncated,
};
return {
files: result.files.map((file) => ({
path: file.path,
size: file.size,
modified: file.modified.toISOString(),
})),
total_found: result.totalFound,
truncated: result.truncated,
_display,
};
},
};
}

View File

@@ -0,0 +1,167 @@
/**
* Grep Content Tool
*
* Internal tool for searching file contents using regex patterns
*/
import * as path from 'node:path';
import { z } from 'zod';
import { InternalTool, ToolExecutionContext, ApprovalType } from '@dexto/core';
import type { SearchDisplayData, ApprovalRequestDetails, ApprovalResponse } from '@dexto/core';
import type { FileToolOptions } from './file-tool-types.js';
const GrepContentInputSchema = z
.object({
pattern: z.string().describe('Regular expression pattern to search for'),
path: z
.string()
.optional()
.describe('Directory to search in (defaults to working directory)'),
glob: z
.string()
.optional()
.describe('Glob pattern to filter files (e.g., "*.ts", "**/*.js")'),
context_lines: z
.number()
.int()
.min(0)
.optional()
.default(0)
.describe(
'Number of context lines to include before and after each match (default: 0)'
),
case_insensitive: z
.boolean()
.optional()
.default(false)
.describe('Perform case-insensitive search (default: false)'),
max_results: z
.number()
.int()
.positive()
.optional()
.default(100)
.describe('Maximum number of results to return (default: 100)'),
})
.strict();
type GrepContentInput = z.input<typeof GrepContentInputSchema>;
/**
* Create the grep_content internal tool with directory approval support
*/
export function createGrepContentTool(options: FileToolOptions): InternalTool {
const { fileSystemService, directoryApproval } = options;
// Store search directory for use in onApprovalGranted callback
let pendingApprovalSearchDir: string | undefined;
return {
id: 'grep_content',
description:
'Search for text patterns in files using regular expressions. Returns matching lines with file path, line number, and optional context lines. Use glob parameter to filter specific file types (e.g., "*.ts"). Supports case-insensitive search. Great for finding code patterns, function definitions, or specific text across multiple files.',
inputSchema: GrepContentInputSchema,
/**
* Check if this grep operation needs directory access approval.
* Returns custom approval request if the search directory is outside allowed paths.
*/
getApprovalOverride: async (args: unknown): Promise<ApprovalRequestDetails | null> => {
const { path: searchPath } = args as GrepContentInput;
// Resolve the search directory (use cwd if not specified)
const searchDir = path.resolve(searchPath || process.cwd());
// Check if path is within config-allowed paths
const isAllowed = await fileSystemService.isPathWithinConfigAllowed(searchDir);
if (isAllowed) {
return null; // Use normal tool confirmation
}
// Check if directory is already session-approved
if (directoryApproval?.isSessionApproved(searchDir)) {
return null; // Already approved, use normal flow
}
// Need directory access approval
pendingApprovalSearchDir = searchDir;
return {
type: ApprovalType.DIRECTORY_ACCESS,
metadata: {
path: searchDir,
parentDir: searchDir,
operation: 'search',
toolName: 'grep_content',
},
};
},
/**
* Handle approved directory access - remember the directory for session
*/
onApprovalGranted: (response: ApprovalResponse): void => {
if (!directoryApproval || !pendingApprovalSearchDir) return;
// Check if user wants to remember the directory
const data = response.data as { rememberDirectory?: boolean } | undefined;
const rememberDirectory = data?.rememberDirectory ?? false;
directoryApproval.addApproved(
pendingApprovalSearchDir,
rememberDirectory ? 'session' : 'once'
);
// Clear pending state
pendingApprovalSearchDir = undefined;
},
execute: async (input: unknown, _context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { pattern, path, glob, context_lines, case_insensitive, max_results } =
input as GrepContentInput;
// Search for content using FileSystemService
const result = await fileSystemService.searchContent(pattern, {
path,
glob,
contextLines: context_lines,
caseInsensitive: case_insensitive,
maxResults: max_results,
});
// Build display data
const _display: SearchDisplayData = {
type: 'search',
pattern,
matches: result.matches.map((match) => ({
file: match.file,
line: match.lineNumber,
content: match.line,
...(match.context && {
context: [...match.context.before, ...match.context.after],
}),
})),
totalMatches: result.totalMatches,
truncated: result.truncated,
};
return {
matches: result.matches.map((match) => ({
file: match.file,
line_number: match.lineNumber,
line: match.line,
...(match.context && {
context: {
before: match.context.before,
after: match.context.after,
},
}),
})),
total_matches: result.totalMatches,
files_searched: result.filesSearched,
truncated: result.truncated,
_display,
};
},
};
}

View File

@@ -0,0 +1,43 @@
/**
* @dexto/tools-filesystem
*
* FileSystem tools provider for Dexto agents.
* Provides file operation tools: read, write, edit, glob, grep.
*/
// Main provider export
export { fileSystemToolsProvider } from './tool-provider.js';
export type { FileToolOptions, DirectoryApprovalCallbacks } from './file-tool-types.js';
// Service and utilities (for advanced use cases)
export { FileSystemService } from './filesystem-service.js';
export { PathValidator } from './path-validator.js';
export { FileSystemError } from './errors.js';
export { FileSystemErrorCode } from './error-codes.js';
// Types
export type {
FileSystemConfig,
FileContent,
ReadFileOptions,
GlobOptions,
GlobResult,
GrepOptions,
SearchResult,
SearchMatch,
WriteFileOptions,
WriteResult,
EditFileOptions,
EditResult,
EditOperation,
FileMetadata,
PathValidation,
BufferEncoding,
} from './types.js';
// Tool implementations (for custom integrations)
export { createReadFileTool } from './read-file-tool.js';
export { createWriteFileTool } from './write-file-tool.js';
export { createEditFileTool } from './edit-file-tool.js';
export { createGlobFilesTool } from './glob-files-tool.js';
export { createGrepContentTool } from './grep-content-tool.js';

View File

@@ -0,0 +1,517 @@
/**
* PathValidator Unit Tests
*
* Tests for path validation, security checks, and allowed path logic.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PathValidator, DirectoryApprovalChecker } from './path-validator.js';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('PathValidator', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
beforeEach(() => {
mockLogger = createMockLogger();
vi.clearAllMocks();
});
describe('validatePath', () => {
describe('Empty and Invalid Paths', () => {
it('should reject empty path', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('');
expect(result.isValid).toBe(false);
expect(result.error).toBe('Path cannot be empty');
});
it('should reject whitespace-only path', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath(' ');
expect(result.isValid).toBe(false);
expect(result.error).toBe('Path cannot be empty');
});
});
describe('Allowed Paths', () => {
it('should allow paths within allowed directories', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('/home/user/project/src/file.ts');
expect(result.isValid).toBe(true);
expect(result.normalizedPath).toBeDefined();
});
it('should allow relative paths within working directory', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('src/file.ts');
expect(result.isValid).toBe(true);
});
it('should reject paths outside allowed directories', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('/external/project/file.ts');
expect(result.isValid).toBe(false);
expect(result.error).toContain('not within allowed paths');
});
it('should allow all paths when allowedPaths is empty', async () => {
const validator = new PathValidator(
{
allowedPaths: [],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('/anywhere/file.ts');
expect(result.isValid).toBe(true);
});
});
describe('Path Traversal Detection', () => {
it('should reject path traversal attempts', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath(
'/home/user/project/../../../etc/passwd'
);
expect(result.isValid).toBe(false);
expect(result.error).toBe('Path traversal detected');
});
});
describe('Blocked Paths', () => {
it('should reject paths in blocked directories', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: ['.git', 'node_modules'],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('/home/user/project/.git/config');
expect(result.isValid).toBe(false);
expect(result.error).toContain('blocked');
});
it('should reject paths in node_modules', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: ['node_modules'],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath(
'/home/user/project/node_modules/lodash/index.js'
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('blocked');
});
});
describe('Blocked Extensions', () => {
it('should reject files with blocked extensions', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: ['.exe', '.dll'],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('/home/user/project/malware.exe');
expect(result.isValid).toBe(false);
expect(result.error).toContain('.exe is not allowed');
});
it('should handle extensions without leading dot', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: ['exe', 'dll'], // No leading dot
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('/home/user/project/file.exe');
expect(result.isValid).toBe(false);
});
it('should be case-insensitive for extensions', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: ['.exe'],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const result = await validator.validatePath('/home/user/project/file.EXE');
expect(result.isValid).toBe(false);
});
});
describe('Directory Approval Checker Integration', () => {
it('should consult approval checker for external paths', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
// Without approval checker, external path should fail
let result = await validator.validatePath('/external/project/file.ts');
expect(result.isValid).toBe(false);
// Set approval checker that approves external path
const approvalChecker: DirectoryApprovalChecker = (filePath) => {
return filePath.startsWith('/external/project');
};
validator.setDirectoryApprovalChecker(approvalChecker);
// Now external path should succeed
result = await validator.validatePath('/external/project/file.ts');
expect(result.isValid).toBe(true);
});
it('should not use approval checker for config-allowed paths', async () => {
const approvalChecker = vi.fn().mockReturnValue(false);
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
validator.setDirectoryApprovalChecker(approvalChecker);
// Config-allowed path should not invoke checker
const result = await validator.validatePath('/home/user/project/src/file.ts');
expect(result.isValid).toBe(true);
expect(approvalChecker).not.toHaveBeenCalled();
});
});
});
describe('isPathWithinAllowed', () => {
it('should return true for paths within config-allowed directories', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
expect(await validator.isPathWithinAllowed('/home/user/project/src/file.ts')).toBe(
true
);
expect(
await validator.isPathWithinAllowed('/home/user/project/deep/nested/file.ts')
).toBe(true);
});
it('should return false for paths outside config-allowed directories', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
expect(await validator.isPathWithinAllowed('/external/project/file.ts')).toBe(false);
expect(await validator.isPathWithinAllowed('/home/user/other/file.ts')).toBe(false);
});
it('should NOT consult approval checker (used for prompting decisions)', async () => {
const approvalChecker = vi.fn().mockReturnValue(true);
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
validator.setDirectoryApprovalChecker(approvalChecker);
// Even with approval checker that returns true, isPathWithinAllowed should return false
// for external paths (it only checks config paths, not approval checker)
expect(await validator.isPathWithinAllowed('/external/project/file.ts')).toBe(false);
expect(approvalChecker).not.toHaveBeenCalled();
});
it('should return false for empty path', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
expect(await validator.isPathWithinAllowed('')).toBe(false);
expect(await validator.isPathWithinAllowed(' ')).toBe(false);
});
it('should return true when allowedPaths is empty (all paths allowed)', async () => {
const validator = new PathValidator(
{
allowedPaths: [],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
expect(await validator.isPathWithinAllowed('/anywhere/file.ts')).toBe(true);
});
});
describe('Path Containment (Parent Directory Coverage)', () => {
it('should recognize that approving parent covers child paths', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/external/sub'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
// Child path of allowed directory
expect(await validator.isPathWithinAllowed('/external/sub/deep/nested/file.ts')).toBe(
true
);
});
it('should not allow sibling directories', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/external/sub'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
// /external/other is sibling, not child
expect(await validator.isPathWithinAllowed('/external/other/file.ts')).toBe(false);
});
it('should not allow parent directories when child is approved', async () => {
const validator = new PathValidator(
{
allowedPaths: ['/external/sub/deep'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
// /external/sub is parent, should not be allowed
expect(await validator.isPathWithinAllowed('/external/sub/file.ts')).toBe(false);
});
});
describe('getAllowedPaths and getBlockedPaths', () => {
it('should return normalized allowed paths', () => {
const validator = new PathValidator(
{
allowedPaths: ['.', './src'],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const allowedPaths = validator.getAllowedPaths();
expect(allowedPaths).toHaveLength(2);
expect(allowedPaths[0]).toBe('/home/user/project');
expect(allowedPaths[1]).toBe('/home/user/project/src');
});
it('should return blocked paths', () => {
const validator = new PathValidator(
{
allowedPaths: ['/home/user/project'],
blockedPaths: ['.git', 'node_modules'],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
enableBackups: false,
backupRetentionDays: 7,
workingDirectory: '/home/user/project',
},
mockLogger as any
);
const blockedPaths = validator.getBlockedPaths();
expect(blockedPaths).toContain('.git');
expect(blockedPaths).toContain('node_modules');
});
});
});

View File

@@ -0,0 +1,307 @@
/**
* Path Validator
*
* Security-focused path validation for file system operations
*/
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { FileSystemConfig, PathValidation } from './types.js';
import type { IDextoLogger } from '@dexto/core';
/**
* Callback type for checking if a path is in an approved directory.
* Used to consult ApprovalManager without creating a direct dependency.
*/
export type DirectoryApprovalChecker = (filePath: string) => boolean;
/**
* PathValidator - Validates file paths for security and policy compliance
*
* Security checks:
* 1. Path traversal detection (../, symbolic links)
* 2. Allowed paths enforcement (whitelist + approved directories)
* 3. Blocked paths detection (blacklist)
* 4. File extension restrictions
* 5. Absolute path normalization
*
* PathValidator can optionally consult an external approval checker (e.g., ApprovalManager)
* to determine if paths outside the config's allowed paths are accessible.
*/
export class PathValidator {
private config: FileSystemConfig;
private normalizedAllowedPaths: string[];
private normalizedBlockedPaths: string[];
private normalizedBlockedExtensions: string[];
private logger: IDextoLogger;
private directoryApprovalChecker: DirectoryApprovalChecker | undefined;
constructor(config: FileSystemConfig, logger: IDextoLogger) {
this.config = config;
this.logger = logger;
// Normalize allowed paths to absolute paths
const workingDir = config.workingDirectory || process.cwd();
this.normalizedAllowedPaths = config.allowedPaths.map((p) => path.resolve(workingDir, p));
// Normalize blocked paths
this.normalizedBlockedPaths = config.blockedPaths.map((p) => path.normalize(p));
// Normalize blocked extensions: ensure leading dot and lowercase
this.normalizedBlockedExtensions = (config.blockedExtensions || []).map((ext) => {
const e = ext.startsWith('.') ? ext : `.${ext}`;
return e.toLowerCase();
});
this.logger.debug(
`PathValidator initialized with ${this.normalizedAllowedPaths.length} allowed paths`
);
}
/**
* Set a callback to check if a path is in an approved directory.
* This allows PathValidator to consult ApprovalManager without a direct dependency.
*
* @param checker Function that returns true if path is in an approved directory
*/
setDirectoryApprovalChecker(checker: DirectoryApprovalChecker): void {
this.directoryApprovalChecker = checker;
this.logger.debug('Directory approval checker configured');
}
/**
* Validate a file path for security and policy compliance
*/
async validatePath(filePath: string): Promise<PathValidation> {
// 1. Check for empty path
if (!filePath || filePath.trim() === '') {
return {
isValid: false,
error: 'Path cannot be empty',
};
}
// 2. Normalize the path to absolute
const workingDir = this.config.workingDirectory || process.cwd();
let normalizedPath: string;
try {
// Handle both absolute and relative paths
normalizedPath = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(workingDir, filePath);
// Canonicalize to handle symlinks and resolve real paths (async, non-blocking)
try {
normalizedPath = await fs.realpath(normalizedPath);
} catch {
// If the path doesn't exist yet (e.g., writes), fallback to the resolved path
// Policy checks continue to use normalizedPath
}
} catch (error) {
return {
isValid: false,
error: `Failed to normalize path: ${error instanceof Error ? error.message : String(error)}`,
};
}
// 3. Check for path traversal attempts
if (this.hasPathTraversal(filePath, normalizedPath)) {
return {
isValid: false,
error: 'Path traversal detected',
};
}
// 4. Check if path is within allowed paths
if (!this.isPathAllowed(normalizedPath)) {
return {
isValid: false,
error: `Path is not within allowed paths. Allowed: ${this.normalizedAllowedPaths.join(', ')}`,
};
}
// 5. Check if path is blocked
const blockedReason = this.isPathBlocked(normalizedPath);
if (blockedReason) {
return {
isValid: false,
error: `Path is blocked: ${blockedReason}`,
};
}
// 6. Check file extension if applicable
const ext = path.extname(normalizedPath).toLowerCase();
if (ext && this.normalizedBlockedExtensions.includes(ext)) {
return {
isValid: false,
error: `File extension ${ext} is not allowed`,
};
}
return {
isValid: true,
normalizedPath,
};
}
/**
* Check if path contains traversal attempts
*/
private hasPathTraversal(originalPath: string, normalizedPath: string): boolean {
// Check for ../ patterns in original path
if (originalPath.includes('../') || originalPath.includes('..\\')) {
// Verify the normalized path still escapes allowed boundaries
const workingDir = this.config.workingDirectory || process.cwd();
const relative = path.relative(workingDir, normalizedPath);
if (relative.startsWith('..')) {
return true;
}
}
return false;
}
/**
* Check if path is within allowed paths (whitelist check)
* Also consults the directory approval checker if configured.
* Uses the sync version since the path is already normalized at this point.
*/
private isPathAllowed(normalizedPath: string): boolean {
return this.isPathAllowedSync(normalizedPath);
}
/**
* Check if path matches blocked patterns (blacklist check)
*/
private isPathBlocked(normalizedPath: string): string | null {
const roots =
this.normalizedAllowedPaths.length > 0
? this.normalizedAllowedPaths
: [this.config.workingDirectory || process.cwd()];
for (const blocked of this.normalizedBlockedPaths) {
for (const root of roots) {
// Resolve blocked relative to each allowed root unless already absolute
const blockedFull = path.isAbsolute(blocked)
? path.normalize(blocked)
: path.resolve(root, blocked);
// Segment-aware prefix check
if (
normalizedPath === blockedFull ||
normalizedPath.startsWith(blockedFull + path.sep)
) {
return `Within blocked directory: ${blocked}`;
}
}
}
return null;
}
/**
* Quick check if a path is allowed (for internal use)
* Note: This assumes the path is already normalized/canonicalized
*/
isPathAllowedQuick(normalizedPath: string): boolean {
return this.isPathAllowedSync(normalizedPath) && !this.isPathBlocked(normalizedPath);
}
/**
* Synchronous path allowed check (for already-normalized paths)
* This is used internally when we already have a canonicalized path
*/
private isPathAllowedSync(normalizedPath: string): boolean {
// Empty allowedPaths means all paths are allowed
if (this.normalizedAllowedPaths.length === 0) {
return true;
}
// Check if path is within any config-allowed path
const isInConfigPaths = this.normalizedAllowedPaths.some((allowedPath) => {
const relative = path.relative(allowedPath, normalizedPath);
// Path is allowed if it doesn't escape the allowed directory
return !relative.startsWith('..') && !path.isAbsolute(relative);
});
if (isInConfigPaths) {
return true;
}
// Fallback: check ApprovalManager via callback (includes working dir + approved dirs)
if (this.directoryApprovalChecker) {
return this.directoryApprovalChecker(normalizedPath);
}
return false;
}
/**
* Check if a file path is within the configured allowed paths (from config only).
* This method does NOT consult ApprovalManager - it only checks the static config paths.
*
* This is used by file tools to determine if a path needs directory approval.
* Paths within config-allowed directories don't need directory approval prompts.
*
* @param filePath The file path to check (can be relative or absolute)
* @returns true if the path is within config-allowed paths, false otherwise
*/
async isPathWithinAllowed(filePath: string): Promise<boolean> {
if (!filePath || filePath.trim() === '') {
return false;
}
// Normalize the path to absolute
const workingDir = this.config.workingDirectory || process.cwd();
let normalizedPath: string;
try {
normalizedPath = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(workingDir, filePath);
// Try to resolve symlinks for existing files (async, non-blocking)
try {
normalizedPath = await fs.realpath(normalizedPath);
} catch {
// Path doesn't exist yet, use resolved path
}
} catch {
// Failed to normalize, treat as not within allowed
return false;
}
// Only check config paths - do NOT consult approval checker here
// This method is used for prompting decisions, not execution decisions
return this.isInConfigAllowedPaths(normalizedPath);
}
/**
* Check if path is within config-allowed paths only (no approval checker).
* Used for prompting decisions.
*/
private isInConfigAllowedPaths(normalizedPath: string): boolean {
// Empty allowedPaths means all paths are allowed
if (this.normalizedAllowedPaths.length === 0) {
return true;
}
return this.normalizedAllowedPaths.some((allowedPath) => {
const relative = path.relative(allowedPath, normalizedPath);
return !relative.startsWith('..') && !path.isAbsolute(relative);
});
}
/**
* Get normalized allowed paths
*/
getAllowedPaths(): string[] {
return [...this.normalizedAllowedPaths];
}
/**
* Get blocked paths
*/
getBlockedPaths(): string[] {
return [...this.normalizedBlockedPaths];
}
}

View File

@@ -0,0 +1,132 @@
/**
* Read File Tool
*
* Internal tool for reading file contents with size limits and pagination
*/
import * as path from 'node:path';
import { z } from 'zod';
import { InternalTool, ToolExecutionContext, ApprovalType } from '@dexto/core';
import type { FileDisplayData, ApprovalRequestDetails, ApprovalResponse } from '@dexto/core';
import type { FileToolOptions } from './file-tool-types.js';
const ReadFileInputSchema = z
.object({
file_path: z.string().describe('Absolute path to the file to read'),
limit: z
.number()
.int()
.positive()
.optional()
.describe('Maximum number of lines to read (optional)'),
offset: z
.number()
.int()
.min(1)
.optional()
.describe('Starting line number (1-based, optional)'),
})
.strict();
type ReadFileInput = z.input<typeof ReadFileInputSchema>;
/**
* Create the read_file internal tool with directory approval support
*/
export function createReadFileTool(options: FileToolOptions): InternalTool {
const { fileSystemService, directoryApproval } = options;
// Store parent directory for use in onApprovalGranted callback
let pendingApprovalParentDir: string | undefined;
return {
id: 'read_file',
description:
'Read the contents of a file with optional pagination. Returns file content, line count, encoding, and whether the output was truncated. Use limit and offset parameters for large files to read specific sections. This tool is for reading files within allowed paths only.',
inputSchema: ReadFileInputSchema,
/**
* Check if this read operation needs directory access approval.
* Returns custom approval request if the file is outside allowed paths.
*/
getApprovalOverride: async (args: unknown): Promise<ApprovalRequestDetails | null> => {
const { file_path } = args as ReadFileInput;
if (!file_path) return null;
// Check if path is within config-allowed paths (async for non-blocking symlink resolution)
const isAllowed = await fileSystemService.isPathWithinConfigAllowed(file_path);
if (isAllowed) {
return null; // Use normal tool confirmation
}
// Check if directory is already session-approved
if (directoryApproval?.isSessionApproved(file_path)) {
return null; // Already approved, use normal flow
}
// Need directory access approval
const absolutePath = path.resolve(file_path);
const parentDir = path.dirname(absolutePath);
pendingApprovalParentDir = parentDir;
return {
type: ApprovalType.DIRECTORY_ACCESS,
metadata: {
path: absolutePath,
parentDir,
operation: 'read',
toolName: 'read_file',
},
};
},
/**
* Handle approved directory access - remember the directory for session
*/
onApprovalGranted: (response: ApprovalResponse): void => {
if (!directoryApproval || !pendingApprovalParentDir) return;
// Check if user wants to remember the directory
// Use type assertion to access rememberDirectory since response.data is a union type
const data = response.data as { rememberDirectory?: boolean } | undefined;
const rememberDirectory = data?.rememberDirectory ?? false;
directoryApproval.addApproved(
pendingApprovalParentDir,
rememberDirectory ? 'session' : 'once'
);
// Clear pending state
pendingApprovalParentDir = undefined;
},
execute: async (input: unknown, _context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { file_path, limit, offset } = input as ReadFileInput;
// Read file using FileSystemService
const result = await fileSystemService.readFile(file_path, {
limit,
offset,
});
// Build display data
const _display: FileDisplayData = {
type: 'file',
path: file_path,
operation: 'read',
size: result.size,
lineCount: result.lines,
};
return {
content: result.content,
lines: result.lines,
encoding: result.encoding,
truncated: result.truncated,
size: result.size,
...(result.mimeType && { mimeType: result.mimeType }),
_display,
};
},
};
}

View File

@@ -0,0 +1,215 @@
/**
* FileSystem Tools Provider
*
* Provides file operation tools by wrapping FileSystemService.
* When registered, the provider initializes FileSystemService and creates tools
* for file operations (read, write, edit, glob, grep).
*/
import { z } from 'zod';
import type { CustomToolProvider, ToolCreationContext } from '@dexto/core';
import type { InternalTool } from '@dexto/core';
import { FileSystemService } from './filesystem-service.js';
import { createReadFileTool } from './read-file-tool.js';
import { createWriteFileTool } from './write-file-tool.js';
import { createEditFileTool } from './edit-file-tool.js';
import { createGlobFilesTool } from './glob-files-tool.js';
import { createGrepContentTool } from './grep-content-tool.js';
import type { FileToolOptions } from './file-tool-types.js';
// Re-export for convenience
export type { FileToolOptions } from './file-tool-types.js';
/**
* Default configuration constants for FileSystem tools.
* These are the SINGLE SOURCE OF TRUTH for all default values.
*/
const DEFAULT_ALLOWED_PATHS = ['.'];
const DEFAULT_BLOCKED_PATHS = ['.git', 'node_modules/.bin', '.env'];
const DEFAULT_BLOCKED_EXTENSIONS = ['.exe', '.dll', '.so'];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const DEFAULT_ENABLE_BACKUPS = false;
const DEFAULT_BACKUP_RETENTION_DAYS = 7;
/**
* Available filesystem tool names for enabledTools configuration.
*/
const FILESYSTEM_TOOL_NAMES = [
'read_file',
'write_file',
'edit_file',
'glob_files',
'grep_content',
] as const;
type FileSystemToolName = (typeof FILESYSTEM_TOOL_NAMES)[number];
/**
* Configuration schema for FileSystem tools provider.
*
* This is the SINGLE SOURCE OF TRUTH for all configuration:
* - Validation rules
* - Default values (using constants above)
* - Documentation
* - Type definitions
*
* Services receive fully-validated config from this schema and use it as-is,
* with no additional defaults or fallbacks needed.
*/
const FileSystemToolsConfigSchema = z
.object({
type: z.literal('filesystem-tools'),
allowedPaths: z
.array(z.string())
.default(DEFAULT_ALLOWED_PATHS)
.describe('List of allowed base paths for file operations'),
blockedPaths: z
.array(z.string())
.default(DEFAULT_BLOCKED_PATHS)
.describe('List of blocked paths to exclude from operations'),
blockedExtensions: z
.array(z.string())
.default(DEFAULT_BLOCKED_EXTENSIONS)
.describe('List of blocked file extensions'),
maxFileSize: z
.number()
.int()
.positive()
.default(DEFAULT_MAX_FILE_SIZE)
.describe(
`Maximum file size in bytes (default: ${DEFAULT_MAX_FILE_SIZE / 1024 / 1024}MB)`
),
workingDirectory: z
.string()
.optional()
.describe('Working directory for file operations (defaults to process.cwd())'),
enableBackups: z
.boolean()
.default(DEFAULT_ENABLE_BACKUPS)
.describe('Enable automatic backups of modified files'),
backupPath: z
.string()
.optional()
.describe('Absolute path for storing file backups (if enableBackups is true)'),
backupRetentionDays: z
.number()
.int()
.positive()
.default(DEFAULT_BACKUP_RETENTION_DAYS)
.describe(
`Number of days to retain backup files (default: ${DEFAULT_BACKUP_RETENTION_DAYS})`
),
enabledTools: z
.array(z.enum(FILESYSTEM_TOOL_NAMES))
.optional()
.describe(
`Subset of tools to enable. If not specified, all tools are enabled. Available: ${FILESYSTEM_TOOL_NAMES.join(', ')}`
),
})
.strict();
type FileSystemToolsConfig = z.output<typeof FileSystemToolsConfigSchema>;
/**
* FileSystem tools provider.
*
* Wraps FileSystemService and provides file operation tools:
* - read_file: Read file contents with pagination
* - write_file: Write or overwrite file contents
* - edit_file: Edit files using search/replace operations
* - glob_files: Find files matching glob patterns
* - grep_content: Search file contents using regex
*
* When registered via customToolRegistry, FileSystemService is automatically
* initialized and file operation tools become available to the agent.
*/
export const fileSystemToolsProvider: CustomToolProvider<
'filesystem-tools',
FileSystemToolsConfig
> = {
type: 'filesystem-tools',
configSchema: FileSystemToolsConfigSchema,
create: (config: FileSystemToolsConfig, context: ToolCreationContext): InternalTool[] => {
const { logger, services } = context;
logger.debug('Creating FileSystemService for filesystem tools');
// Create FileSystemService with validated config
const fileSystemService = new FileSystemService(
{
allowedPaths: config.allowedPaths,
blockedPaths: config.blockedPaths,
blockedExtensions: config.blockedExtensions,
maxFileSize: config.maxFileSize,
workingDirectory: config.workingDirectory || process.cwd(),
enableBackups: config.enableBackups,
backupPath: config.backupPath,
backupRetentionDays: config.backupRetentionDays,
},
logger
);
// Start initialization in background - service methods use ensureInitialized() for lazy init
// This means tools will wait for initialization to complete before executing
fileSystemService.initialize().catch((error) => {
logger.error(`Failed to initialize FileSystemService: ${error.message}`);
});
logger.debug('FileSystemService created - initialization will complete on first tool use');
// Set up directory approval checker callback if approvalManager is available
// This allows FileSystemService to check approved directories during validation
const approvalManager = services?.approvalManager;
if (approvalManager) {
const approvalChecker = (filePath: string) => {
// Use isDirectoryApproved() for EXECUTION decisions (checks both 'session' and 'once' types)
// isDirectorySessionApproved() is only for PROMPTING decisions (checks 'session' type only)
return approvalManager.isDirectoryApproved(filePath);
};
fileSystemService.setDirectoryApprovalChecker(approvalChecker);
logger.debug('Directory approval checker configured for FileSystemService');
}
// Create directory approval callbacks for file tools
// These allow tools to check and request directory approval
const directoryApproval = approvalManager
? {
isSessionApproved: (filePath: string) =>
approvalManager.isDirectorySessionApproved(filePath),
addApproved: (directory: string, type: 'session' | 'once') =>
approvalManager.addApprovedDirectory(directory, type),
}
: undefined;
// Create options for file tools with directory approval support
const fileToolOptions: FileToolOptions = {
fileSystemService,
directoryApproval,
};
// Build tool map for selective enabling
const toolCreators: Record<FileSystemToolName, () => InternalTool> = {
read_file: () => createReadFileTool(fileToolOptions),
write_file: () => createWriteFileTool(fileToolOptions),
edit_file: () => createEditFileTool(fileToolOptions),
glob_files: () => createGlobFilesTool(fileToolOptions),
grep_content: () => createGrepContentTool(fileToolOptions),
};
// Determine which tools to create
const toolsToCreate = config.enabledTools ?? FILESYSTEM_TOOL_NAMES;
if (config.enabledTools) {
logger.debug(`Creating subset of filesystem tools: ${toolsToCreate.join(', ')}`);
}
// Create and return only the enabled tools
return toolsToCreate.map((toolName) => toolCreators[toolName]());
},
metadata: {
displayName: 'FileSystem Tools',
description: 'File system operations (read, write, edit, glob, grep)',
category: 'filesystem',
},
};

View File

@@ -0,0 +1,204 @@
/**
* FileSystem Service Types
*
* Types and interfaces for file system operations including reading, writing,
* searching, and validation.
*/
// BufferEncoding type from Node.js
export type BufferEncoding =
| 'ascii'
| 'utf8'
| 'utf-8'
| 'utf16le'
| 'ucs2'
| 'ucs-2'
| 'base64'
| 'base64url'
| 'latin1'
| 'binary'
| 'hex';
/**
* File content with metadata
*/
export interface FileContent {
content: string;
lines: number;
encoding: string;
mimeType?: string;
truncated: boolean;
size: number;
}
/**
* Options for reading files
*/
export interface ReadFileOptions {
/** Maximum number of lines to read */
limit?: number | undefined;
/** Starting line number (1-based) */
offset?: number | undefined;
/** File encoding (default: utf-8) */
encoding?: BufferEncoding | undefined;
}
/**
* File metadata for glob results
*/
export interface FileMetadata {
path: string;
size: number;
modified: Date;
isDirectory: boolean;
}
/**
* Options for glob operations
*/
export interface GlobOptions {
/** Base directory to search from */
cwd?: string | undefined;
/** Maximum number of results */
maxResults?: number | undefined;
/** Include file metadata */
includeMetadata?: boolean | undefined;
}
/**
* Glob result
*/
export interface GlobResult {
files: FileMetadata[];
truncated: boolean;
totalFound: number;
}
/**
* Search match with context
*/
export interface SearchMatch {
file: string;
lineNumber: number;
line: string;
context?: {
before: string[];
after: string[];
};
}
/**
* Options for content search (grep)
*/
export interface GrepOptions {
/** Base directory to search */
path?: string | undefined;
/** Glob pattern to filter files */
glob?: string | undefined;
/** Number of context lines before/after match */
contextLines?: number | undefined;
/** Case-insensitive search */
caseInsensitive?: boolean | undefined;
/** Maximum number of results */
maxResults?: number | undefined;
/** Include line numbers */
lineNumbers?: boolean | undefined;
}
/**
* Search result
*/
export interface SearchResult {
matches: SearchMatch[];
totalMatches: number;
truncated: boolean;
filesSearched: number;
}
/**
* Options for writing files
*/
export interface WriteFileOptions {
/** Create parent directories if they don't exist */
createDirs?: boolean | undefined;
/** File encoding (default: utf-8) */
encoding?: BufferEncoding | undefined;
/** Create backup before overwriting */
backup?: boolean | undefined;
}
/**
* Write result
*/
export interface WriteResult {
success: boolean;
path: string;
bytesWritten: number;
backupPath?: string | undefined;
/** Original content if file was overwritten (undefined for new files) */
originalContent?: string | undefined;
}
/**
* Edit operation
*/
export interface EditOperation {
oldString: string;
newString: string;
replaceAll?: boolean | undefined;
}
/**
* Options for editing files
*/
export interface EditFileOptions {
/** Create backup before editing */
backup?: boolean;
/** File encoding */
encoding?: BufferEncoding;
}
/**
* Edit result
*/
export interface EditResult {
success: boolean;
path: string;
changesCount: number;
backupPath?: string | undefined;
/** Original content before edit (for diff generation) */
originalContent: string;
/** New content after edit (for diff generation) */
newContent: string;
}
/**
* Path validation result
*/
export interface PathValidation {
isValid: boolean;
error?: string;
normalizedPath?: string;
}
/**
* File system configuration
*/
export interface FileSystemConfig {
/** Allowed base paths */
allowedPaths: string[];
/** Blocked paths (relative to allowed paths) */
blockedPaths: string[];
/** Blocked file extensions */
blockedExtensions: string[];
/** Maximum file size in bytes */
maxFileSize: number;
/** Enable automatic backups */
enableBackups: boolean;
/** Backup directory absolute path (required when enableBackups is true - provided by CLI enrichment) */
backupPath?: string | undefined;
/** Backup retention period in days (default: 7) */
backupRetentionDays: number;
/** Working directory for glob/grep operations (defaults to process.cwd()) */
workingDirectory?: string | undefined;
}

View File

@@ -0,0 +1,281 @@
/**
* Write File Tool Tests
*
* Tests for the write_file tool including file modification detection.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { createWriteFileTool } from './write-file-tool.js';
import { FileSystemService } from './filesystem-service.js';
import { ToolErrorCode } from '@dexto/core';
import { DextoRuntimeError } from '@dexto/core';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('write_file tool', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
let fileSystemService: FileSystemService;
beforeEach(async () => {
mockLogger = createMockLogger();
// Create temp directory for testing
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-write-test-'));
tempDir = await fs.realpath(rawTempDir);
fileSystemService = new FileSystemService(
{
allowedPaths: [tempDir],
blockedPaths: [],
blockedExtensions: [],
maxFileSize: 10 * 1024 * 1024,
workingDirectory: tempDir,
enableBackups: false,
backupRetentionDays: 7,
},
mockLogger as any
);
await fileSystemService.initialize();
vi.clearAllMocks();
});
afterEach(async () => {
// Cleanup temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('File Modification Detection - Existing Files', () => {
it('should succeed when existing file is not modified between preview and execute', async () => {
const tool = createWriteFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'original content');
const toolCallId = 'test-call-123';
const input = {
file_path: testFile,
content: 'new content',
};
// Generate preview (stores hash)
const preview = await tool.generatePreview!(input, { toolCallId });
expect(preview).toBeDefined();
expect(preview?.type).toBe('diff');
// Execute without modifying file (should succeed)
const result = (await tool.execute(input, { toolCallId })) as {
success: boolean;
path: string;
};
expect(result.success).toBe(true);
expect(result.path).toBe(testFile);
// Verify file was written
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('new content');
});
it('should fail when existing file is modified between preview and execute', async () => {
const tool = createWriteFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'original content');
const toolCallId = 'test-call-456';
const input = {
file_path: testFile,
content: 'new content',
};
// Generate preview (stores hash)
await tool.generatePreview!(input, { toolCallId });
// Simulate user modifying the file externally
await fs.writeFile(testFile, 'user modified this');
// Execute should fail because file was modified
try {
await tool.execute(input, { toolCallId });
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
);
}
// Verify file was NOT modified by the tool (still has user's changes)
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('user modified this');
});
it('should fail when existing file is deleted between preview and execute', async () => {
const tool = createWriteFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'original content');
const toolCallId = 'test-call-deleted';
const input = {
file_path: testFile,
content: 'new content',
};
// Generate preview (stores hash of existing file)
await tool.generatePreview!(input, { toolCallId });
// Simulate user deleting the file
await fs.unlink(testFile);
// Execute should fail because file was deleted
try {
await tool.execute(input, { toolCallId });
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
);
}
});
});
describe('File Modification Detection - New Files', () => {
it('should succeed when creating new file that still does not exist', async () => {
const tool = createWriteFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'new-file.txt');
const toolCallId = 'test-call-new';
const input = {
file_path: testFile,
content: 'brand new content',
};
// Generate preview (stores marker that file doesn't exist)
const preview = await tool.generatePreview!(input, { toolCallId });
expect(preview).toBeDefined();
expect(preview?.type).toBe('file');
expect((preview as any).operation).toBe('create');
// Execute (file still doesn't exist - should succeed)
const result = (await tool.execute(input, { toolCallId })) as { success: boolean };
expect(result.success).toBe(true);
// Verify file was created
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('brand new content');
});
it('should fail when file is created by someone else between preview and execute', async () => {
const tool = createWriteFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'race-condition.txt');
const toolCallId = 'test-call-race';
const input = {
file_path: testFile,
content: 'agent content',
};
// Generate preview (file doesn't exist)
const preview = await tool.generatePreview!(input, { toolCallId });
expect(preview?.type).toBe('file');
// Simulate someone else creating the file
await fs.writeFile(testFile, 'someone else created this');
// Execute should fail because file now exists
try {
await tool.execute(input, { toolCallId });
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
);
}
// Verify the other person's file is preserved
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('someone else created this');
});
});
describe('Cache Cleanup', () => {
it('should clean up hash cache after successful execution', async () => {
const tool = createWriteFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'original');
const toolCallId = 'test-call-cleanup';
const input = {
file_path: testFile,
content: 'first write',
};
// First write
await tool.generatePreview!(input, { toolCallId });
await tool.execute(input, { toolCallId });
// Second write with same toolCallId should work
const input2 = {
file_path: testFile,
content: 'second write',
};
await tool.generatePreview!(input2, { toolCallId });
const result = (await tool.execute(input2, { toolCallId })) as { success: boolean };
expect(result.success).toBe(true);
const content = await fs.readFile(testFile, 'utf-8');
expect(content).toBe('second write');
});
it('should clean up hash cache after failed execution', async () => {
const tool = createWriteFileTool({ fileSystemService });
const testFile = path.join(tempDir, 'test.txt');
await fs.writeFile(testFile, 'original');
const toolCallId = 'test-call-fail';
const input = {
file_path: testFile,
content: 'new content',
};
// Preview
await tool.generatePreview!(input, { toolCallId });
// Modify to cause failure
await fs.writeFile(testFile, 'modified');
// Execute fails
try {
await tool.execute(input, { toolCallId });
} catch {
// Expected
}
// Reset file
await fs.writeFile(testFile, 'reset content');
// Next execution with same toolCallId should work
await tool.generatePreview!(input, { toolCallId });
const result = (await tool.execute(input, { toolCallId })) as { success: boolean };
expect(result.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,294 @@
/**
* Write File Tool
*
* Internal tool for writing content to files (requires approval)
*/
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { z } from 'zod';
import { createPatch } from 'diff';
import {
InternalTool,
ToolExecutionContext,
DextoRuntimeError,
ApprovalType,
ToolError,
} from '@dexto/core';
import type {
DiffDisplayData,
FileDisplayData,
ApprovalRequestDetails,
ApprovalResponse,
} from '@dexto/core';
import { FileSystemErrorCode } from './error-codes.js';
import { BufferEncoding } from './types.js';
import type { FileToolOptions } from './file-tool-types.js';
/**
* Cache for content hashes between preview and execute phases.
* Keyed by toolCallId to ensure proper cleanup after execution.
* This prevents file corruption when user modifies file between preview and execute.
*
* For new files (file doesn't exist at preview time), we store a special marker
* to detect if the file was created between preview and execute.
*/
const previewContentHashCache = new Map<string, string | null>();
/** Marker for files that didn't exist at preview time */
const FILE_NOT_EXISTS_MARKER = null;
/**
* Compute SHA-256 hash of content for change detection
*/
function computeContentHash(content: string): string {
return createHash('sha256').update(content, 'utf8').digest('hex');
}
const WriteFileInputSchema = z
.object({
file_path: z.string().describe('Absolute path where the file should be written'),
content: z.string().describe('Content to write to the file'),
create_dirs: z
.boolean()
.optional()
.default(false)
.describe("Create parent directories if they don't exist (default: false)"),
encoding: z
.enum(['utf-8', 'ascii', 'latin1', 'utf16le'])
.optional()
.default('utf-8')
.describe('File encoding (default: utf-8)'),
})
.strict();
type WriteFileInput = z.input<typeof WriteFileInputSchema>;
/**
* Generate diff preview without modifying the file
*/
function generateDiffPreview(
filePath: string,
originalContent: string,
newContent: string
): DiffDisplayData {
const unified = createPatch(filePath, originalContent, newContent, 'before', 'after', {
context: 3,
});
const additions = (unified.match(/^\+[^+]/gm) || []).length;
const deletions = (unified.match(/^-[^-]/gm) || []).length;
return {
type: 'diff',
unified,
filename: filePath,
additions,
deletions,
};
}
/**
* Create the write_file internal tool with directory approval support
*/
export function createWriteFileTool(options: FileToolOptions): InternalTool {
const { fileSystemService, directoryApproval } = options;
// Store parent directory for use in onApprovalGranted callback
let pendingApprovalParentDir: string | undefined;
return {
id: 'write_file',
description:
'Write content to a file. Creates a new file or overwrites existing file. Automatically creates backup of existing files before overwriting. Use create_dirs to create parent directories. Requires approval for all write operations. Returns success status, path, bytes written, and backup path if applicable.',
inputSchema: WriteFileInputSchema,
/**
* Check if this write operation needs directory access approval.
* Returns custom approval request if the file is outside allowed paths.
*/
getApprovalOverride: async (args: unknown): Promise<ApprovalRequestDetails | null> => {
const { file_path } = args as WriteFileInput;
if (!file_path) return null;
// Check if path is within config-allowed paths (async for non-blocking symlink resolution)
const isAllowed = await fileSystemService.isPathWithinConfigAllowed(file_path);
if (isAllowed) {
return null; // Use normal tool confirmation
}
// Check if directory is already session-approved
if (directoryApproval?.isSessionApproved(file_path)) {
return null; // Already approved, use normal flow
}
// Need directory access approval
const absolutePath = path.resolve(file_path);
const parentDir = path.dirname(absolutePath);
pendingApprovalParentDir = parentDir;
return {
type: ApprovalType.DIRECTORY_ACCESS,
metadata: {
path: absolutePath,
parentDir,
operation: 'write',
toolName: 'write_file',
},
};
},
/**
* Handle approved directory access - remember the directory for session
*/
onApprovalGranted: (response: ApprovalResponse): void => {
if (!directoryApproval || !pendingApprovalParentDir) return;
// Check if user wants to remember the directory
// Use type assertion to access rememberDirectory since response.data is a union type
const data = response.data as { rememberDirectory?: boolean } | undefined;
const rememberDirectory = data?.rememberDirectory ?? false;
directoryApproval.addApproved(
pendingApprovalParentDir,
rememberDirectory ? 'session' : 'once'
);
// Clear pending state
pendingApprovalParentDir = undefined;
},
/**
* Generate preview for approval UI - shows diff or file creation info
* Stores content hash for change detection in execute phase.
*/
generatePreview: async (input: unknown, context?: ToolExecutionContext) => {
const { file_path, content } = input as WriteFileInput;
try {
// Try to read existing file
const originalFile = await fileSystemService.readFile(file_path);
const originalContent = originalFile.content;
// Store content hash for change detection in execute phase
if (context?.toolCallId) {
previewContentHashCache.set(
context.toolCallId,
computeContentHash(originalContent)
);
}
// File exists - show diff preview
return generateDiffPreview(file_path, originalContent, content);
} catch (error) {
// Only treat FILE_NOT_FOUND as "create new file", rethrow other errors
if (
error instanceof DextoRuntimeError &&
error.code === FileSystemErrorCode.FILE_NOT_FOUND
) {
// Store marker that file didn't exist at preview time
if (context?.toolCallId) {
previewContentHashCache.set(context.toolCallId, FILE_NOT_EXISTS_MARKER);
}
// File doesn't exist - show as file creation with full content
const lineCount = content.split('\n').length;
const preview: FileDisplayData = {
type: 'file',
path: file_path,
operation: 'create',
size: content.length,
lineCount,
content, // Include content for approval preview
};
return preview;
}
// Permission denied, I/O errors, etc. - rethrow
throw error;
}
},
execute: async (input: unknown, context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { file_path, content, create_dirs, encoding } = input as WriteFileInput;
// Check if file was modified since preview (safety check)
// This prevents corrupting user edits made between preview approval and execution
let originalContent: string | null = null;
let fileExistsNow = false;
try {
const originalFile = await fileSystemService.readFile(file_path);
originalContent = originalFile.content;
fileExistsNow = true;
} catch (error) {
// Only treat FILE_NOT_FOUND as "create new file", rethrow other errors
if (
error instanceof DextoRuntimeError &&
error.code === FileSystemErrorCode.FILE_NOT_FOUND
) {
// File doesn't exist - this is a create operation
originalContent = null;
fileExistsNow = false;
} else {
// Permission denied, I/O errors, etc. - rethrow
throw error;
}
}
// Verify file hasn't changed since preview
if (context?.toolCallId && previewContentHashCache.has(context.toolCallId)) {
const expectedHash = previewContentHashCache.get(context.toolCallId);
previewContentHashCache.delete(context.toolCallId); // Clean up regardless of outcome
if (expectedHash === FILE_NOT_EXISTS_MARKER) {
// File didn't exist at preview time - verify it still doesn't exist
if (fileExistsNow) {
throw ToolError.fileModifiedSincePreview('write_file', file_path);
}
} else if (expectedHash !== null) {
// File existed at preview time - verify content hasn't changed
if (!fileExistsNow) {
// File was deleted between preview and execute
throw ToolError.fileModifiedSincePreview('write_file', file_path);
}
const currentHash = computeContentHash(originalContent!);
if (expectedHash !== currentHash) {
throw ToolError.fileModifiedSincePreview('write_file', file_path);
}
}
}
// Write file using FileSystemService
// Backup behavior is controlled by config.enableBackups (default: false)
const result = await fileSystemService.writeFile(file_path, content, {
createDirs: create_dirs,
encoding: encoding as BufferEncoding,
});
// Build display data based on operation type
let _display: DiffDisplayData | FileDisplayData;
if (originalContent === null) {
// New file creation
const lineCount = content.split('\n').length;
_display = {
type: 'file',
path: file_path,
operation: 'create',
size: result.bytesWritten,
lineCount,
};
} else {
// File overwrite - generate diff using shared helper
_display = generateDiffPreview(file_path, originalContent, content);
}
return {
success: result.success,
path: result.path,
bytes_written: result.bytesWritten,
...(result.backupPath && { backup_path: result.backupPath }),
_display,
};
},
};
}