@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
JavaScript
;
/**
* 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
});
}