UNPKG

@vfarcic/dot-ai

Version:

AI-powered development productivity platform that enhances software development workflows through intelligent automation and AI-driven assistance

412 lines (411 loc) 15.1 kB
"use strict"; /** * Internal Agentic-Loop Tools (PRD #407, PRD #408) * * Tools that run locally in the MCP server, available to the AI * during investigation loops alongside plugin tools. NOT exposed * to client agents — only the AI inside toolLoop() calls these. * * Tools: * - git_clone: Clone a Git repo * - fs_list: List files at a path * - fs_read: Read a file at a path * - git_create_pr: Create a PR with file changes (PRD #408) * * All filesystem operations are scoped to ./tmp/gitops-clones/ * to prevent path traversal attacks. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.validatePathWithinClones = validatePathWithinClones; exports.getInternalTools = getInternalTools; exports.createInternalToolExecutor = createInternalToolExecutor; exports.cleanupOldClones = cleanupOldClones; const fs = __importStar(require("fs")); const path = __importStar(require("path")); const git_utils_js_1 = require("./git-utils.js"); const solution_utils_js_1 = require("./solution-utils.js"); const simple_git_1 = __importDefault(require("simple-git")); const CLONES_SUBDIR = 'gitops-clones'; const MAX_FILE_SIZE = 100 * 1024; // 100KB const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour // ─── Path security ─── function getClonesDir() { return path.resolve(process.cwd(), 'tmp', CLONES_SUBDIR); } /** * Validate that a relative path is safe and resolve it within the clones directory. * Uses sanitizeRelativePath for traversal checks, then resolves to absolute. * Exported for testing. */ function validatePathWithinClones(inputPath) { // Decode URL-encoded characters (e.g., %2e%2e/ for ../) before validation let decoded; try { decoded = decodeURIComponent(inputPath); } catch { decoded = inputPath; } const sanitized = (0, git_utils_js_1.sanitizeRelativePath)(decoded); return path.resolve(getClonesDir(), sanitized); } // ─── Repo name sanitization ─── function repoUrlToDirectoryName(repoUrl) { try { const url = new URL(repoUrl); const repoPath = url.pathname.replace(/^\//, '').replace(/\.git$/, ''); return (0, solution_utils_js_1.sanitizeIntentForLabel)(repoPath); } catch { return (0, solution_utils_js_1.sanitizeIntentForLabel)(repoUrl.slice(0, 63)); } } // ─── Tool definitions ─── /** * Returns internal tools available to the AI during investigation. * Note: git_create_pr is executor-only and not exposed to investigation. */ function getInternalTools() { return [ { name: 'git_clone', description: 'Clone a Git repository. Returns a relative path to the cloned repo.', inputSchema: { type: 'object', properties: { repoUrl: { type: 'string', description: 'Repository URL (HTTPS)', }, }, required: ['repoUrl'], }, }, { name: 'fs_list', description: 'List files and directories at a relative path within the working directory.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path to list', }, }, required: ['path'], }, }, { name: 'fs_read', description: 'Read file contents at a relative path within the working directory.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path to file', }, }, required: ['path'], }, }, // git_create_pr is executor-only - not exposed during investigation // It's only available via createInternalToolExecutor() during execution ]; } // ─── Handlers ─── async function handleGitClone(args, sessionId) { const repoUrl = args.repoUrl; if (!repoUrl) { return 'Error: repoUrl is required'; } const repoName = repoUrlToDirectoryName(repoUrl); const relativePath = path.join(sessionId, repoName); const targetDir = path.join(getClonesDir(), relativePath); if (fs.existsSync(targetDir)) { return { localPath: relativePath, message: 'Repository already cloned' }; } const parentDir = path.dirname(targetDir); if (!fs.existsSync(parentDir)) { fs.mkdirSync(parentDir, { recursive: true }); } try { const result = await (0, git_utils_js_1.cloneRepo)(repoUrl, targetDir, { depth: 1 }); return { localPath: relativePath, branch: result.branch }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return `Error cloning repository: ${(0, git_utils_js_1.scrubCredentials)(message)}`; } } function handleFsList(args) { const inputPath = args.path; if (!inputPath) { return 'Error: path is required'; } let resolved; try { resolved = validatePathWithinClones(inputPath); } catch (err) { return `Error: ${err instanceof Error ? err.message : String(err)}`; } if (!fs.existsSync(resolved)) { return `Error: path does not exist: ${inputPath}`; } const stat = fs.statSync(resolved); if (!stat.isDirectory()) { return `Error: path is not a directory: ${inputPath}`; } const entries = fs.readdirSync(resolved, { withFileTypes: true }); return entries.map(entry => ({ name: entry.name, type: entry.isDirectory() ? 'directory' : 'file', })); } function handleFsRead(args) { const inputPath = args.path; if (!inputPath) { return 'Error: path is required'; } let resolved; try { resolved = validatePathWithinClones(inputPath); } catch (err) { return `Error: ${err instanceof Error ? err.message : String(err)}`; } if (!fs.existsSync(resolved)) { return `Error: file does not exist: ${inputPath}`; } const stat = fs.statSync(resolved); if (stat.isDirectory()) { return `Error: path is a directory, not a file: ${inputPath}`; } // Binary detection: check first 8KB for null bytes const detectBuffer = Buffer.alloc(Math.min(8192, stat.size)); const fd = fs.openSync(resolved, 'r'); try { fs.readSync(fd, detectBuffer, 0, detectBuffer.length, 0); } finally { fs.closeSync(fd); } if (detectBuffer.includes(0)) { return 'Binary file, cannot display'; } if (stat.size > MAX_FILE_SIZE) { const buffer = Buffer.alloc(MAX_FILE_SIZE); const fd = fs.openSync(resolved, 'r'); try { const bytesRead = fs.readSync(fd, buffer, 0, MAX_FILE_SIZE, 0); const content = buffer.toString('utf-8', 0, bytesRead); return `${content}\n\n[Truncated: file exceeds ${MAX_FILE_SIZE / 1024}KB limit]`; } finally { fs.closeSync(fd); } } return fs.readFileSync(resolved, 'utf-8'); } async function handleGitCreatePr(args) { const repoPath = args.repoPath; const files = args.files; const title = args.title; const body = args.body || ''; const branchName = args.branchName; const baseBranch = args.baseBranch || 'main'; if (!repoPath) { return { success: false, error: 'repoPath is required' }; } if (!files || !Array.isArray(files) || files.length === 0) { return { success: false, error: 'files array is required and must not be empty', }; } if (!title) { return { success: false, error: 'title is required' }; } if (!branchName) { return { success: false, error: 'branchName is required' }; } let resolvedRepoPath; try { resolvedRepoPath = validatePathWithinClones(repoPath); } catch (err) { return { success: false, error: `Invalid repo path: ${err instanceof Error ? err.message : String(err)}`, }; } if (!fs.existsSync(resolvedRepoPath)) { return { success: false, error: `Repository not found at path: ${repoPath}. It may have been cleaned up.`, }; } const stat = fs.statSync(resolvedRepoPath); if (!stat.isDirectory()) { return { success: false, error: `Path is not a directory: ${repoPath}` }; } try { const git = (0, simple_git_1.default)(resolvedRepoPath); await git.checkout(baseBranch); const pushResult = await (0, git_utils_js_1.pushRepo)(resolvedRepoPath, files, title, { branch: branchName, }); const authConfig = (0, git_utils_js_1.getGitAuthConfigFromEnv)(); const token = await (0, git_utils_js_1.getAuthToken)(authConfig); const remotes = await git.getRemotes(true); const origin = remotes.find(r => r.name === 'origin'); if (!origin?.refs?.fetch) { return { success: false, error: 'Could not find origin remote URL' }; } const remoteUrl = (0, git_utils_js_1.scrubCredentials)(origin.refs.fetch); const repoMatch = remoteUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/); if (!repoMatch) { return { success: true, branch: branchName, baseBranch, filesChanged: pushResult.filesAdded, error: 'Automatic PR creation is only supported for GitHub repositories. Changes were pushed to the branch — create a PR/MR manually.', }; } const ownerRepo = repoMatch[1]; const [owner, repo] = ownerRepo.split('/'); const prResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/vnd.github+json', 'User-Agent': 'dot-ai-remediate', }, body: JSON.stringify({ title, body, head: branchName, base: baseBranch, // Test-only switch: integration tests set this so PRs they create // don't trigger CodeRabbit (which has drafts: false in .coderabbit.yaml). // Production never sets this env var. ...(process.env.DOT_AI_GIT_CREATE_DRAFT_PRS === 'true' && { draft: true, }), }), signal: AbortSignal.timeout(30000), }); if (!prResponse.ok) { const errorBody = await prResponse.text(); return { success: false, error: `GitHub API error (${prResponse.status}): ${errorBody}`, }; } const prData = (await prResponse.json()); return { success: true, prUrl: prData.html_url, prNumber: prData.number, branch: branchName, baseBranch, filesChanged: pushResult.filesAdded, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { success: false, error: (0, git_utils_js_1.scrubCredentials)(message) }; } } // ─── Combined executor ─── /** * Create a ToolExecutor that handles internal agentic-loop tools. * Designed to be passed as the fallbackExecutor to pluginManager.createToolExecutor(). */ function createInternalToolExecutor(sessionId) { const handlers = { git_clone: args => handleGitClone(args, sessionId), fs_list: handleFsList, fs_read: handleFsRead, git_create_pr: handleGitCreatePr, }; return async (toolName, input) => { const handler = handlers[toolName]; if (!handler) { return `Error: unknown internal tool: ${toolName}`; } if (typeof input !== 'object' || input === null || Array.isArray(input)) { return 'Error: tool input must be an object'; } return handler(input); }; } // ─── TTL cleanup ─── /** * Remove session clone directories older than the TTL. * Called at the start of each new remediate investigation. * Non-blocking: runs cleanup in the background without delaying investigation. */ function cleanupOldClones(maxAgeMs = DEFAULT_TTL_MS) { const clonesDir = getClonesDir(); fs.promises .access(clonesDir) .then(async () => { const now = Date.now(); const entries = await fs.promises.readdir(clonesDir); for (const entry of entries) { const entryPath = path.join(clonesDir, entry); try { const stat = await fs.promises.stat(entryPath); if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) { await fs.promises.rm(entryPath, { recursive: true, force: true }); } } catch { // Ignore errors during cleanup (e.g., concurrent deletion) } } }) .catch(() => { // Directory doesn't exist yet, nothing to clean }); }