Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
388 lines
14 KiB
JavaScript
388 lines
14 KiB
JavaScript
/**
|
|
* File System Tool Handlers
|
|
*
|
|
* Handles: read_file, write_file, list_directory, search_files, edit_file
|
|
* Also exports shared FS security utilities used by other modules.
|
|
*/
|
|
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import { aiLogger } from '../../utils/logger.js';
|
|
|
|
// === FILE SYSTEM SECURITY ===
|
|
export const PROJECT_ROOT = process.cwd();
|
|
export const FS_MAX_READ_SIZE = 100 * 1024; // 100KB
|
|
export const FS_MAX_SEARCH_MATCHES = 50;
|
|
export const FS_MAX_SEARCH_FILES = 500;
|
|
export const FS_MAX_DIR_DEPTH = 2;
|
|
|
|
export const BLOCKED_DIRS = ['node_modules', '.git', 'backups'];
|
|
export const SENSITIVE_PATTERNS = [/^\.env/, /\.key$/, /\.pem$/, /id_rsa/];
|
|
|
|
/**
|
|
* Validate and resolve a file path for FS operations.
|
|
* Returns the absolute path or throws an error.
|
|
*/
|
|
export function validateFilePath(filePath) {
|
|
if (!filePath || typeof filePath !== 'string') {
|
|
throw new Error('File path is required');
|
|
}
|
|
|
|
// Resolve relative to project root
|
|
const resolved = path.resolve(PROJECT_ROOT, filePath);
|
|
|
|
// Must be within project root
|
|
if (!resolved.startsWith(PROJECT_ROOT + path.sep) && resolved !== PROJECT_ROOT) {
|
|
throw new Error('Access denied: path is outside the project root');
|
|
}
|
|
|
|
// Block parent traversal (even if resolve already handled it, be explicit)
|
|
if (filePath.includes('..')) {
|
|
throw new Error('Access denied: path traversal (..) is not allowed');
|
|
}
|
|
|
|
// Block restricted directories
|
|
const relative = path.relative(PROJECT_ROOT, resolved);
|
|
const parts = relative.split(path.sep);
|
|
for (const dir of BLOCKED_DIRS) {
|
|
if (parts.includes(dir)) {
|
|
throw new Error(`Access denied: "${dir}" directory is restricted`);
|
|
}
|
|
}
|
|
|
|
// Block sensitive files
|
|
const basename = path.basename(resolved);
|
|
for (const pattern of SENSITIVE_PATTERNS) {
|
|
if (pattern.test(basename)) {
|
|
throw new Error(`Access denied: "${basename}" is a sensitive file`);
|
|
}
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Create an auto-backup before writing/editing a file.
|
|
*/
|
|
export async function autoBackup(absolutePath) {
|
|
try {
|
|
await fs.access(absolutePath);
|
|
const ts = Date.now();
|
|
const backupPath = `${absolutePath}.bak-agent-${ts}`;
|
|
await fs.copyFile(absolutePath, backupPath);
|
|
aiLogger.debug({ backupPath }, 'FS auto-backup created');
|
|
return backupPath;
|
|
} catch {
|
|
// File doesn't exist yet, no backup needed
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* File system tool handlers
|
|
*/
|
|
export const fileToolHandlers = {
|
|
async read_file({ path: filePath, line_start, line_end }) {
|
|
try {
|
|
const abs = validateFilePath(filePath);
|
|
const stat = await fs.stat(abs);
|
|
if (!stat.isFile()) return { error: 'Not a file' };
|
|
if (stat.size > FS_MAX_READ_SIZE) {
|
|
// Read truncated
|
|
const handle = await fs.open(abs, 'r');
|
|
const buf = Buffer.alloc(FS_MAX_READ_SIZE);
|
|
await handle.read(buf, 0, FS_MAX_READ_SIZE, 0);
|
|
await handle.close();
|
|
let content = buf.toString('utf-8');
|
|
if (line_start || line_end) {
|
|
const lines = content.split('\n');
|
|
const start = Math.max(1, line_start || 1) - 1;
|
|
const end = line_end ? Math.min(line_end, lines.length) : lines.length;
|
|
content = lines.slice(start, end).join('\n');
|
|
}
|
|
return { success: true, path: filePath, content, truncated: true, size: stat.size };
|
|
}
|
|
let content = await fs.readFile(abs, 'utf-8');
|
|
if (line_start || line_end) {
|
|
const lines = content.split('\n');
|
|
const start = Math.max(1, line_start || 1) - 1;
|
|
const end = line_end ? Math.min(line_end, lines.length) : lines.length;
|
|
content = lines.slice(start, end).join('\n');
|
|
}
|
|
return { success: true, path: filePath, content, size: stat.size, lines: content.split('\n').length };
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
},
|
|
|
|
async write_file({ path: filePath, content }) {
|
|
try {
|
|
const abs = validateFilePath(filePath);
|
|
const backupPath = await autoBackup(abs);
|
|
// Ensure parent directory exists
|
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
await fs.writeFile(abs, content, 'utf-8');
|
|
const stat = await fs.stat(abs);
|
|
aiLogger.info({ path: filePath, size: stat.size, backup: backupPath }, 'write_file executed');
|
|
return {
|
|
success: true,
|
|
path: filePath,
|
|
size: stat.size,
|
|
backup: backupPath ? path.relative(PROJECT_ROOT, backupPath) : null,
|
|
message: `File written (${stat.size} bytes)${backupPath ? ', backup created' : ''}`
|
|
};
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
},
|
|
|
|
async list_directory({ path: dirPath = '.', depth = 1, pattern }) {
|
|
try {
|
|
const abs = validateFilePath(dirPath);
|
|
const effectiveDepth = Math.min(depth, FS_MAX_DIR_DEPTH);
|
|
const stat = await fs.stat(abs);
|
|
if (!stat.isDirectory()) return { error: 'Not a directory' };
|
|
|
|
async function listRecursive(dir, currentDepth) {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
const results = [];
|
|
for (const entry of entries) {
|
|
// Skip blocked directories
|
|
if (BLOCKED_DIRS.includes(entry.name)) continue;
|
|
// Skip hidden files at root level
|
|
if (entry.name.startsWith('.') && currentDepth === 0) continue;
|
|
|
|
const relPath = path.relative(PROJECT_ROOT, path.join(dir, entry.name));
|
|
const isDir = entry.isDirectory();
|
|
|
|
// Apply pattern filter (simple extension match)
|
|
if (pattern && !isDir) {
|
|
const ext = pattern.replace('*', '');
|
|
if (!entry.name.endsWith(ext)) continue;
|
|
}
|
|
|
|
const item = { name: entry.name, path: relPath, type: isDir ? 'directory' : 'file' };
|
|
if (!isDir) {
|
|
try {
|
|
const s = await fs.stat(path.join(dir, entry.name));
|
|
item.size = s.size;
|
|
} catch { /* skip */ }
|
|
}
|
|
results.push(item);
|
|
|
|
if (isDir && currentDepth < effectiveDepth - 1) {
|
|
try {
|
|
item.children = await listRecursive(path.join(dir, entry.name), currentDepth + 1);
|
|
} catch { /* skip unreadable dirs */ }
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
const entries = await listRecursive(abs, 0);
|
|
return { success: true, path: dirPath, depth: effectiveDepth, entries, count: entries.length };
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
},
|
|
|
|
async search_files({ query, path: searchPath = '.', file_pattern, case_sensitive = false }) {
|
|
try {
|
|
const abs = validateFilePath(searchPath);
|
|
const stat = await fs.stat(abs);
|
|
if (!stat.isDirectory()) return { error: 'Search path must be a directory' };
|
|
|
|
const flags = case_sensitive ? 'g' : 'gi';
|
|
let regex;
|
|
try {
|
|
regex = new RegExp(query, flags);
|
|
} catch {
|
|
// Fall back to literal match
|
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
regex = new RegExp(escaped, flags);
|
|
}
|
|
|
|
const matches = [];
|
|
let filesScanned = 0;
|
|
|
|
async function scanDir(dir) {
|
|
if (filesScanned >= FS_MAX_SEARCH_FILES || matches.length >= FS_MAX_SEARCH_MATCHES) return;
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch { return; }
|
|
|
|
for (const entry of entries) {
|
|
if (filesScanned >= FS_MAX_SEARCH_FILES || matches.length >= FS_MAX_SEARCH_MATCHES) return;
|
|
if (BLOCKED_DIRS.includes(entry.name)) continue;
|
|
if (entry.name.startsWith('.')) continue;
|
|
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await scanDir(fullPath);
|
|
} else if (entry.isFile()) {
|
|
// Apply file pattern filter
|
|
if (file_pattern) {
|
|
const ext = file_pattern.replace('*', '');
|
|
if (!entry.name.endsWith(ext)) continue;
|
|
}
|
|
// Skip binary/large files
|
|
try {
|
|
const s = await fs.stat(fullPath);
|
|
if (s.size > FS_MAX_READ_SIZE) continue;
|
|
} catch { continue; }
|
|
|
|
filesScanned++;
|
|
try {
|
|
const content = await fs.readFile(fullPath, 'utf-8');
|
|
const lines = content.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (matches.length >= FS_MAX_SEARCH_MATCHES) break;
|
|
if (regex.test(lines[i])) {
|
|
matches.push({
|
|
file: path.relative(PROJECT_ROOT, fullPath),
|
|
line: i + 1,
|
|
content: lines[i].trim().substring(0, 200)
|
|
});
|
|
}
|
|
// Reset regex lastIndex for global flag
|
|
regex.lastIndex = 0;
|
|
}
|
|
} catch { /* skip unreadable files */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
await scanDir(abs);
|
|
return {
|
|
success: true,
|
|
query,
|
|
matches_count: matches.length,
|
|
files_scanned: filesScanned,
|
|
matches,
|
|
...(matches.length >= FS_MAX_SEARCH_MATCHES ? { truncated: true, message: `Results limited to ${FS_MAX_SEARCH_MATCHES} matches` } : {})
|
|
};
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
},
|
|
|
|
async edit_file({ path: filePath, old_text, new_text, replace_all = false }) {
|
|
try {
|
|
const abs = validateFilePath(filePath);
|
|
const content = await fs.readFile(abs, 'utf-8');
|
|
|
|
if (!content.includes(old_text)) {
|
|
return { error: 'old_text not found in file. Make sure it matches exactly (including whitespace).' };
|
|
}
|
|
|
|
// Check uniqueness when not using replace_all
|
|
if (!replace_all) {
|
|
const count = content.split(old_text).length - 1;
|
|
if (count > 1) {
|
|
return { error: `old_text found ${count} times. Use replace_all: true or provide more unique text.` };
|
|
}
|
|
}
|
|
|
|
const backupPath = await autoBackup(abs);
|
|
let newContent;
|
|
if (replace_all) {
|
|
newContent = content.split(old_text).join(new_text);
|
|
} else {
|
|
const idx = content.indexOf(old_text);
|
|
newContent = content.substring(0, idx) + new_text + content.substring(idx + old_text.length);
|
|
}
|
|
|
|
await fs.writeFile(abs, newContent, 'utf-8');
|
|
const replacements = replace_all ? content.split(old_text).length - 1 : 1;
|
|
aiLogger.info({ path: filePath, replacements, backup: backupPath }, 'edit_file executed');
|
|
return {
|
|
success: true,
|
|
path: filePath,
|
|
replacements,
|
|
backup: backupPath ? path.relative(PROJECT_ROOT, backupPath) : null,
|
|
message: `${replacements} replacement(s) made${backupPath ? ', backup created' : ''}`
|
|
};
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
},
|
|
|
|
// === ADR-144 P0: Upload file from local path or URL ===
|
|
// T-138802: project_id is now optional. files.project_id is nullable post
|
|
// migration 053; system uploads (avatars, agent generations) genuinely
|
|
// have no owning project. Visibility defaults to 'internal' per ADR-0016 P5.
|
|
async upload_file({ source, space_id, project_id, folder = 'mcp', description = '', visibility = 'internal' }, userId) {
|
|
const { dbRun, sqlNow } = await import('../../database/connection.js');
|
|
|
|
const UPLOAD_BASE_PATH = process.env.UPLOAD_PATH || '/var/lib/business-crm-data/uploads';
|
|
const targetDir = path.join(UPLOAD_BASE_PATH, folder);
|
|
await fs.mkdir(targetDir, { recursive: true });
|
|
|
|
let fileName, filePath, fileSize, mimeType;
|
|
|
|
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
// Download from URL
|
|
const response = await fetch(source);
|
|
if (!response.ok) return { error: `Failed to download: ${response.status}` };
|
|
|
|
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
|
mimeType = contentType.split(';')[0];
|
|
|
|
// Extract filename from URL or content-disposition
|
|
const urlPath = new URL(source).pathname;
|
|
fileName = path.basename(urlPath) || `download_${Date.now()}`;
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
fileSize = buffer.length;
|
|
|
|
const uniqueName = `${Date.now()}_${fileName}`;
|
|
filePath = path.join(targetDir, uniqueName);
|
|
await fs.writeFile(filePath, buffer);
|
|
fileName = uniqueName;
|
|
} else {
|
|
// Local file
|
|
const abs = path.resolve(source);
|
|
try {
|
|
const stat = await fs.stat(abs);
|
|
fileSize = stat.size;
|
|
} catch {
|
|
return { error: `File not found: ${source}` };
|
|
}
|
|
|
|
const { default: mime } = await import('mime-types').catch(() => ({ default: { lookup: () => 'application/octet-stream' } }));
|
|
mimeType = (typeof mime.lookup === 'function' ? mime.lookup(abs) : null) || 'application/octet-stream';
|
|
|
|
fileName = `${Date.now()}_${path.basename(abs)}`;
|
|
filePath = path.join(targetDir, fileName);
|
|
await fs.copyFile(abs, filePath);
|
|
}
|
|
|
|
const relativePath = `${folder}/${fileName}`;
|
|
const fileUrl = `/uploads/${relativePath}`;
|
|
const fileId = `file_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
|
|
|
|
// ADR-0016 §Phase 5: agent-uploaded files (chat tool calls,
|
|
// image-generators, fetch tools) need to render inline in chat for any
|
|
// logged-in user. Default visibility 'internal' since these are orphan
|
|
// files (column_id null) and would otherwise fall through to private.
|
|
// T-138802: project_id passed through (nullable) so callers can scope
|
|
// when relevant; defaults null for system/agent uploads.
|
|
const VALID = ['private', 'internal', 'public'];
|
|
const safeVisibility = VALID.includes(visibility) ? visibility : 'internal';
|
|
await dbRun(`
|
|
INSERT INTO files (id, name, original_name, mime_type, size, path, url, storage_provider_id, space_id, project_id, uploaded_by, description, visibility, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'local', ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
|
|
`, [fileId, fileName, path.basename(source), mimeType, fileSize, filePath, fileUrl, space_id || null, project_id || null, userId || 1, description, safeVisibility]);
|
|
|
|
return {
|
|
success: true,
|
|
file_id: fileId,
|
|
url: fileUrl,
|
|
size: fileSize,
|
|
mime_type: mimeType,
|
|
message: `File uploaded: ${fileUrl}`
|
|
};
|
|
}
|
|
};
|