@saber2pr/ai-agent 0.0.10 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/core/agent-chain.d.ts +1 -2
- package/lib/core/agent-chain.js +42 -51
- package/lib/core/agent.d.ts +1 -1
- package/lib/core/agent.js +15 -25
- package/lib/index.d.ts +1 -0
- package/lib/index.js +3 -1
- package/lib/tools/builtin.d.ts +0 -2
- package/lib/tools/builtin.js +5 -91
- package/lib/tools/filesystem/index.d.ts +1 -0
- package/lib/tools/filesystem/index.js +338 -0
- package/lib/tools/filesystem/lib.d.ts +34 -0
- package/lib/tools/filesystem/lib.js +330 -0
- package/lib/tools/filesystem/path-utils.d.ts +18 -0
- package/lib/tools/filesystem/path-utils.js +111 -0
- package/lib/tools/filesystem/path-validation.d.ts +9 -0
- package/lib/tools/filesystem/path-validation.js +81 -0
- package/lib/tools/filesystem/roots-utils.d.ts +12 -0
- package/lib/tools/filesystem/roots-utils.js +76 -0
- package/lib/tools/ts-lsp/index.d.ts +1 -0
- package/lib/tools/ts-lsp/index.js +56 -0
- package/lib/types/type.d.ts +1 -8
- package/lib/utils/createTool.d.ts +1 -3
- package/lib/utils/createTool.js +0 -3
- package/package.json +5 -2
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.setAllowedDirectories = setAllowedDirectories;
|
|
7
|
+
exports.getAllowedDirectories = getAllowedDirectories;
|
|
8
|
+
exports.formatSize = formatSize;
|
|
9
|
+
exports.normalizeLineEndings = normalizeLineEndings;
|
|
10
|
+
exports.createUnifiedDiff = createUnifiedDiff;
|
|
11
|
+
exports.validatePath = validatePath;
|
|
12
|
+
exports.getFileStats = getFileStats;
|
|
13
|
+
exports.readFileContent = readFileContent;
|
|
14
|
+
exports.writeFileContent = writeFileContent;
|
|
15
|
+
exports.applyFileEdits = applyFileEdits;
|
|
16
|
+
exports.tailFile = tailFile;
|
|
17
|
+
exports.headFile = headFile;
|
|
18
|
+
exports.searchFilesWithValidation = searchFilesWithValidation;
|
|
19
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const crypto_1 = require("crypto");
|
|
22
|
+
const diff_1 = require("diff");
|
|
23
|
+
const minimatch_1 = require("minimatch");
|
|
24
|
+
const path_utils_js_1 = require("./path-utils.js");
|
|
25
|
+
const path_validation_js_1 = require("./path-validation.js");
|
|
26
|
+
// Global allowed directories - set by the main module
|
|
27
|
+
let allowedDirectories = [];
|
|
28
|
+
// Function to set allowed directories from the main module
|
|
29
|
+
function setAllowedDirectories(directories) {
|
|
30
|
+
allowedDirectories = [...directories];
|
|
31
|
+
}
|
|
32
|
+
// Function to get current allowed directories
|
|
33
|
+
function getAllowedDirectories() {
|
|
34
|
+
return [...allowedDirectories];
|
|
35
|
+
}
|
|
36
|
+
// Pure Utility Functions
|
|
37
|
+
function formatSize(bytes) {
|
|
38
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
39
|
+
if (bytes === 0)
|
|
40
|
+
return '0 B';
|
|
41
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
42
|
+
if (i < 0 || i === 0)
|
|
43
|
+
return `${bytes} ${units[0]}`;
|
|
44
|
+
const unitIndex = Math.min(i, units.length - 1);
|
|
45
|
+
return `${(bytes / Math.pow(1024, unitIndex)).toFixed(2)} ${units[unitIndex]}`;
|
|
46
|
+
}
|
|
47
|
+
function normalizeLineEndings(text) {
|
|
48
|
+
return text.replace(/\r\n/g, '\n');
|
|
49
|
+
}
|
|
50
|
+
function createUnifiedDiff(originalContent, newContent, filepath = 'file') {
|
|
51
|
+
// Ensure consistent line endings for diff
|
|
52
|
+
const normalizedOriginal = normalizeLineEndings(originalContent);
|
|
53
|
+
const normalizedNew = normalizeLineEndings(newContent);
|
|
54
|
+
return (0, diff_1.createTwoFilesPatch)(filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified');
|
|
55
|
+
}
|
|
56
|
+
// Security & Validation Functions
|
|
57
|
+
async function validatePath(cwd, requestedPath) {
|
|
58
|
+
const expandedPath = (0, path_utils_js_1.expandHome)(requestedPath);
|
|
59
|
+
const absolute = path_1.default.isAbsolute(expandedPath)
|
|
60
|
+
? path_1.default.resolve(expandedPath)
|
|
61
|
+
: path_1.default.resolve(cwd, expandedPath);
|
|
62
|
+
const normalizedRequested = (0, path_utils_js_1.normalizePath)(absolute);
|
|
63
|
+
// Security: Check if path is within allowed directories before any file operations
|
|
64
|
+
const isAllowed = (0, path_validation_js_1.isPathWithinAllowedDirectories)(normalizedRequested, allowedDirectories);
|
|
65
|
+
if (!isAllowed) {
|
|
66
|
+
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
// Security: Handle symlinks by checking their real path to prevent symlink attacks
|
|
69
|
+
// This prevents attackers from creating symlinks that point outside allowed directories
|
|
70
|
+
try {
|
|
71
|
+
const realPath = await promises_1.default.realpath(absolute);
|
|
72
|
+
const normalizedReal = (0, path_utils_js_1.normalizePath)(realPath);
|
|
73
|
+
if (!(0, path_validation_js_1.isPathWithinAllowedDirectories)(normalizedReal, allowedDirectories)) {
|
|
74
|
+
throw new Error(`Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
return realPath;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Security: For new files that don't exist yet, verify parent directory
|
|
80
|
+
// This ensures we can't create files in unauthorized locations
|
|
81
|
+
if (error.code === 'ENOENT') {
|
|
82
|
+
const parentDir = path_1.default.dirname(absolute);
|
|
83
|
+
try {
|
|
84
|
+
const realParentPath = await promises_1.default.realpath(parentDir);
|
|
85
|
+
const normalizedParent = (0, path_utils_js_1.normalizePath)(realParentPath);
|
|
86
|
+
if (!(0, path_validation_js_1.isPathWithinAllowedDirectories)(normalizedParent, allowedDirectories)) {
|
|
87
|
+
throw new Error(`Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
return absolute;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error(`Parent directory does not exist: ${parentDir}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// File Operations
|
|
99
|
+
async function getFileStats(filePath) {
|
|
100
|
+
const stats = await promises_1.default.stat(filePath);
|
|
101
|
+
return {
|
|
102
|
+
size: stats.size,
|
|
103
|
+
created: stats.birthtime,
|
|
104
|
+
modified: stats.mtime,
|
|
105
|
+
accessed: stats.atime,
|
|
106
|
+
isDirectory: stats.isDirectory(),
|
|
107
|
+
isFile: stats.isFile(),
|
|
108
|
+
permissions: stats.mode.toString(8).slice(-3),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function readFileContent(filePath, encoding = 'utf-8') {
|
|
112
|
+
return await promises_1.default.readFile(filePath, encoding);
|
|
113
|
+
}
|
|
114
|
+
async function writeFileContent(filePath, content) {
|
|
115
|
+
try {
|
|
116
|
+
// Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists,
|
|
117
|
+
// preventing writes through pre-existing symlinks
|
|
118
|
+
await promises_1.default.writeFile(filePath, content, { encoding: "utf-8", flag: 'wx' });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code === 'EEXIST') {
|
|
122
|
+
// Security: Use atomic rename to prevent race conditions where symlinks
|
|
123
|
+
// could be created between validation and write. Rename operations
|
|
124
|
+
// replace the target file atomically and don't follow symlinks.
|
|
125
|
+
const tempPath = `${filePath}.${(0, crypto_1.randomBytes)(16).toString('hex')}.tmp`;
|
|
126
|
+
try {
|
|
127
|
+
await promises_1.default.writeFile(tempPath, content, 'utf-8');
|
|
128
|
+
await promises_1.default.rename(tempPath, filePath);
|
|
129
|
+
}
|
|
130
|
+
catch (renameError) {
|
|
131
|
+
try {
|
|
132
|
+
await promises_1.default.unlink(tempPath);
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
throw renameError;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function applyFileEdits(filePath, edits, dryRun = false) {
|
|
144
|
+
var _a;
|
|
145
|
+
// Read file content and normalize line endings
|
|
146
|
+
const content = normalizeLineEndings(await promises_1.default.readFile(filePath, 'utf-8'));
|
|
147
|
+
// Apply edits sequentially
|
|
148
|
+
let modifiedContent = content;
|
|
149
|
+
for (const edit of edits) {
|
|
150
|
+
const normalizedOld = normalizeLineEndings(edit.oldText);
|
|
151
|
+
const normalizedNew = normalizeLineEndings(edit.newText);
|
|
152
|
+
// If exact match exists, use it
|
|
153
|
+
if (modifiedContent.includes(normalizedOld)) {
|
|
154
|
+
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// Otherwise, try line-by-line matching with flexibility for whitespace
|
|
158
|
+
const oldLines = normalizedOld.split('\n');
|
|
159
|
+
const contentLines = modifiedContent.split('\n');
|
|
160
|
+
let matchFound = false;
|
|
161
|
+
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
|
|
162
|
+
const potentialMatch = contentLines.slice(i, i + oldLines.length);
|
|
163
|
+
// Compare lines with normalized whitespace
|
|
164
|
+
const isMatch = oldLines.every((oldLine, j) => {
|
|
165
|
+
const contentLine = potentialMatch[j];
|
|
166
|
+
return oldLine.trim() === contentLine.trim();
|
|
167
|
+
});
|
|
168
|
+
if (isMatch) {
|
|
169
|
+
// Preserve original indentation of first line
|
|
170
|
+
const originalIndent = ((_a = contentLines[i].match(/^\s*/)) === null || _a === void 0 ? void 0 : _a[0]) || '';
|
|
171
|
+
const newLines = normalizedNew.split('\n').map((line, j) => {
|
|
172
|
+
var _a, _b, _c;
|
|
173
|
+
if (j === 0)
|
|
174
|
+
return originalIndent + line.trimStart();
|
|
175
|
+
// For subsequent lines, try to preserve relative indentation
|
|
176
|
+
const oldIndent = ((_b = (_a = oldLines[j]) === null || _a === void 0 ? void 0 : _a.match(/^\s*/)) === null || _b === void 0 ? void 0 : _b[0]) || '';
|
|
177
|
+
const newIndent = ((_c = line.match(/^\s*/)) === null || _c === void 0 ? void 0 : _c[0]) || '';
|
|
178
|
+
if (oldIndent && newIndent) {
|
|
179
|
+
const relativeIndent = newIndent.length - oldIndent.length;
|
|
180
|
+
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
|
|
181
|
+
}
|
|
182
|
+
return line;
|
|
183
|
+
});
|
|
184
|
+
contentLines.splice(i, oldLines.length, ...newLines);
|
|
185
|
+
modifiedContent = contentLines.join('\n');
|
|
186
|
+
matchFound = true;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!matchFound) {
|
|
191
|
+
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Create unified diff
|
|
195
|
+
const diff = createUnifiedDiff(content, modifiedContent, filePath);
|
|
196
|
+
// Format diff with appropriate number of backticks
|
|
197
|
+
let numBackticks = 3;
|
|
198
|
+
while (diff.includes('`'.repeat(numBackticks))) {
|
|
199
|
+
numBackticks++;
|
|
200
|
+
}
|
|
201
|
+
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
|
|
202
|
+
if (!dryRun) {
|
|
203
|
+
// Security: Use atomic rename to prevent race conditions where symlinks
|
|
204
|
+
// could be created between validation and write. Rename operations
|
|
205
|
+
// replace the target file atomically and don't follow symlinks.
|
|
206
|
+
const tempPath = `${filePath}.${(0, crypto_1.randomBytes)(16).toString('hex')}.tmp`;
|
|
207
|
+
try {
|
|
208
|
+
await promises_1.default.writeFile(tempPath, modifiedContent, 'utf-8');
|
|
209
|
+
await promises_1.default.rename(tempPath, filePath);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
try {
|
|
213
|
+
await promises_1.default.unlink(tempPath);
|
|
214
|
+
}
|
|
215
|
+
catch { }
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return formattedDiff;
|
|
220
|
+
}
|
|
221
|
+
// Memory-efficient implementation to get the last N lines of a file
|
|
222
|
+
async function tailFile(filePath, numLines) {
|
|
223
|
+
const CHUNK_SIZE = 1024; // Read 1KB at a time
|
|
224
|
+
const stats = await promises_1.default.stat(filePath);
|
|
225
|
+
const fileSize = stats.size;
|
|
226
|
+
if (fileSize === 0)
|
|
227
|
+
return '';
|
|
228
|
+
// Open file for reading
|
|
229
|
+
const fileHandle = await promises_1.default.open(filePath, 'r');
|
|
230
|
+
try {
|
|
231
|
+
const lines = [];
|
|
232
|
+
let position = fileSize;
|
|
233
|
+
let chunk = Buffer.alloc(CHUNK_SIZE);
|
|
234
|
+
let linesFound = 0;
|
|
235
|
+
let remainingText = '';
|
|
236
|
+
// Read chunks from the end of the file until we have enough lines
|
|
237
|
+
while (position > 0 && linesFound < numLines) {
|
|
238
|
+
const size = Math.min(CHUNK_SIZE, position);
|
|
239
|
+
position -= size;
|
|
240
|
+
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
|
|
241
|
+
if (!bytesRead)
|
|
242
|
+
break;
|
|
243
|
+
// Get the chunk as a string and prepend any remaining text from previous iteration
|
|
244
|
+
const readData = chunk.slice(0, bytesRead).toString('utf-8');
|
|
245
|
+
const chunkText = readData + remainingText;
|
|
246
|
+
// Split by newlines and count
|
|
247
|
+
const chunkLines = normalizeLineEndings(chunkText).split('\n');
|
|
248
|
+
// If this isn't the end of the file, the first line is likely incomplete
|
|
249
|
+
// Save it to prepend to the next chunk
|
|
250
|
+
if (position > 0) {
|
|
251
|
+
remainingText = chunkLines[0];
|
|
252
|
+
chunkLines.shift(); // Remove the first (incomplete) line
|
|
253
|
+
}
|
|
254
|
+
// Add lines to our result (up to the number we need)
|
|
255
|
+
for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) {
|
|
256
|
+
lines.unshift(chunkLines[i]);
|
|
257
|
+
linesFound++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return lines.join('\n');
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await fileHandle.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// New function to get the first N lines of a file
|
|
267
|
+
async function headFile(filePath, numLines) {
|
|
268
|
+
const fileHandle = await promises_1.default.open(filePath, 'r');
|
|
269
|
+
try {
|
|
270
|
+
const lines = [];
|
|
271
|
+
let buffer = '';
|
|
272
|
+
let bytesRead = 0;
|
|
273
|
+
const chunk = Buffer.alloc(1024); // 1KB buffer
|
|
274
|
+
// Read chunks and count lines until we have enough or reach EOF
|
|
275
|
+
while (lines.length < numLines) {
|
|
276
|
+
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
|
|
277
|
+
if (result.bytesRead === 0)
|
|
278
|
+
break; // End of file
|
|
279
|
+
bytesRead += result.bytesRead;
|
|
280
|
+
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
|
|
281
|
+
const newLineIndex = buffer.lastIndexOf('\n');
|
|
282
|
+
if (newLineIndex !== -1) {
|
|
283
|
+
const completeLines = buffer.slice(0, newLineIndex).split('\n');
|
|
284
|
+
buffer = buffer.slice(newLineIndex + 1);
|
|
285
|
+
for (const line of completeLines) {
|
|
286
|
+
lines.push(line);
|
|
287
|
+
if (lines.length >= numLines)
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// If there is leftover content and we still need lines, add it
|
|
293
|
+
if (buffer.length > 0 && lines.length < numLines) {
|
|
294
|
+
lines.push(buffer);
|
|
295
|
+
}
|
|
296
|
+
return lines.join('\n');
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
await fileHandle.close();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function searchFilesWithValidation(cwd, rootPath, pattern, allowedDirectories, options = {}) {
|
|
303
|
+
const { excludePatterns = [] } = options;
|
|
304
|
+
const results = [];
|
|
305
|
+
async function search(currentPath) {
|
|
306
|
+
const entries = await promises_1.default.readdir(currentPath, { withFileTypes: true });
|
|
307
|
+
for (const entry of entries) {
|
|
308
|
+
const fullPath = path_1.default.join(currentPath, entry.name);
|
|
309
|
+
try {
|
|
310
|
+
await validatePath(cwd, fullPath);
|
|
311
|
+
const relativePath = path_1.default.relative(rootPath, fullPath);
|
|
312
|
+
const shouldExclude = excludePatterns.some(excludePattern => (0, minimatch_1.minimatch)(relativePath, excludePattern, { dot: true }));
|
|
313
|
+
if (shouldExclude)
|
|
314
|
+
continue;
|
|
315
|
+
// Use glob matching for the search pattern
|
|
316
|
+
if ((0, minimatch_1.minimatch)(relativePath, pattern, { dot: true })) {
|
|
317
|
+
results.push(fullPath);
|
|
318
|
+
}
|
|
319
|
+
if (entry.isDirectory()) {
|
|
320
|
+
await search(fullPath);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await search(rootPath);
|
|
329
|
+
return results;
|
|
330
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts WSL or Unix-style Windows paths to Windows format
|
|
3
|
+
* @param p The path to convert
|
|
4
|
+
* @returns Converted Windows path
|
|
5
|
+
*/
|
|
6
|
+
export declare function convertToWindowsPath(p: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes path by standardizing format while preserving OS-specific behavior
|
|
9
|
+
* @param p The path to normalize
|
|
10
|
+
* @returns Normalized path
|
|
11
|
+
*/
|
|
12
|
+
export declare function normalizePath(p: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Expands home directory tildes in paths
|
|
15
|
+
* @param filepath The path to expand
|
|
16
|
+
* @returns Expanded path
|
|
17
|
+
*/
|
|
18
|
+
export declare function expandHome(filepath: string): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.convertToWindowsPath = convertToWindowsPath;
|
|
7
|
+
exports.normalizePath = normalizePath;
|
|
8
|
+
exports.expandHome = expandHome;
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
/**
|
|
12
|
+
* Converts WSL or Unix-style Windows paths to Windows format
|
|
13
|
+
* @param p The path to convert
|
|
14
|
+
* @returns Converted Windows path
|
|
15
|
+
*/
|
|
16
|
+
function convertToWindowsPath(p) {
|
|
17
|
+
// Handle WSL paths (/mnt/c/...)
|
|
18
|
+
// NEVER convert WSL paths - they are valid Linux paths that work with Node.js fs operations in WSL
|
|
19
|
+
// Converting them to Windows format (C:\...) breaks fs operations inside WSL
|
|
20
|
+
if (p.startsWith('/mnt/')) {
|
|
21
|
+
return p; // Leave WSL paths unchanged
|
|
22
|
+
}
|
|
23
|
+
// Handle Unix-style Windows paths (/c/...)
|
|
24
|
+
// Only convert when running on Windows
|
|
25
|
+
if (p.match(/^\/[a-zA-Z]\//) && process.platform === 'win32') {
|
|
26
|
+
const driveLetter = p.charAt(1).toUpperCase();
|
|
27
|
+
const pathPart = p.slice(2).replace(/\//g, '\\');
|
|
28
|
+
return `${driveLetter}:${pathPart}`;
|
|
29
|
+
}
|
|
30
|
+
// Handle standard Windows paths, ensuring backslashes
|
|
31
|
+
if (p.match(/^[a-zA-Z]:/)) {
|
|
32
|
+
return p.replace(/\//g, '\\');
|
|
33
|
+
}
|
|
34
|
+
// Leave non-Windows paths unchanged
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Normalizes path by standardizing format while preserving OS-specific behavior
|
|
39
|
+
* @param p The path to normalize
|
|
40
|
+
* @returns Normalized path
|
|
41
|
+
*/
|
|
42
|
+
function normalizePath(p) {
|
|
43
|
+
// Remove any surrounding quotes and whitespace
|
|
44
|
+
p = p.trim().replace(/^["']|["']$/g, '');
|
|
45
|
+
// Check if this is a Unix path that should not be converted
|
|
46
|
+
// WSL paths (/mnt/) should ALWAYS be preserved as they work correctly in WSL with Node.js fs
|
|
47
|
+
// Regular Unix paths should also be preserved
|
|
48
|
+
const isUnixPath = p.startsWith('/') && (
|
|
49
|
+
// Always preserve WSL paths (/mnt/c/, /mnt/d/, etc.)
|
|
50
|
+
p.match(/^\/mnt\/[a-z]\//i) ||
|
|
51
|
+
// On non-Windows platforms, treat all absolute paths as Unix paths
|
|
52
|
+
(process.platform !== 'win32') ||
|
|
53
|
+
// On Windows, preserve Unix paths that aren't Unix-style Windows paths (/c/, /d/, etc.)
|
|
54
|
+
(process.platform === 'win32' && !p.match(/^\/[a-zA-Z]\//)));
|
|
55
|
+
if (isUnixPath) {
|
|
56
|
+
// For Unix paths, just normalize without converting to Windows format
|
|
57
|
+
// Replace double slashes with single slashes and remove trailing slashes
|
|
58
|
+
return p.replace(/\/+/g, '/').replace(/(?<!^)\/$/, '');
|
|
59
|
+
}
|
|
60
|
+
// Convert Unix-style Windows paths (/c/, /d/) to Windows format if on Windows
|
|
61
|
+
// This function will now leave /mnt/ paths unchanged
|
|
62
|
+
p = convertToWindowsPath(p);
|
|
63
|
+
// Handle double backslashes, preserving leading UNC \\
|
|
64
|
+
if (p.startsWith('\\\\')) {
|
|
65
|
+
// For UNC paths, first normalize any excessive leading backslashes to exactly \\
|
|
66
|
+
// Then normalize double backslashes in the rest of the path
|
|
67
|
+
let uncPath = p;
|
|
68
|
+
// Replace multiple leading backslashes with exactly two
|
|
69
|
+
uncPath = uncPath.replace(/^\\{2,}/, '\\\\');
|
|
70
|
+
// Now normalize any remaining double backslashes in the rest of the path
|
|
71
|
+
const restOfPath = uncPath.substring(2).replace(/\\\\/g, '\\');
|
|
72
|
+
p = '\\\\' + restOfPath;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// For non-UNC paths, normalize all double backslashes
|
|
76
|
+
p = p.replace(/\\\\/g, '\\');
|
|
77
|
+
}
|
|
78
|
+
// Use Node's path normalization, which handles . and .. segments
|
|
79
|
+
let normalized = path_1.default.normalize(p);
|
|
80
|
+
// Fix UNC paths after normalization (path.normalize can remove a leading backslash)
|
|
81
|
+
if (p.startsWith('\\\\') && !normalized.startsWith('\\\\')) {
|
|
82
|
+
normalized = '\\' + normalized;
|
|
83
|
+
}
|
|
84
|
+
// Handle Windows paths: convert slashes and ensure drive letter is capitalized
|
|
85
|
+
if (normalized.match(/^[a-zA-Z]:/)) {
|
|
86
|
+
let result = normalized.replace(/\//g, '\\');
|
|
87
|
+
// Capitalize drive letter if present
|
|
88
|
+
if (/^[a-z]:/.test(result)) {
|
|
89
|
+
result = result.charAt(0).toUpperCase() + result.slice(1);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
// On Windows, convert forward slashes to backslashes for relative paths
|
|
94
|
+
// On Linux/Unix, preserve forward slashes
|
|
95
|
+
if (process.platform === 'win32') {
|
|
96
|
+
return normalized.replace(/\//g, '\\');
|
|
97
|
+
}
|
|
98
|
+
// On non-Windows platforms, keep the normalized path as-is
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Expands home directory tildes in paths
|
|
103
|
+
* @param filepath The path to expand
|
|
104
|
+
* @returns Expanded path
|
|
105
|
+
*/
|
|
106
|
+
function expandHome(filepath) {
|
|
107
|
+
if (filepath.startsWith('~/') || filepath === '~') {
|
|
108
|
+
return path_1.default.join(os_1.default.homedir(), filepath.slice(1));
|
|
109
|
+
}
|
|
110
|
+
return filepath;
|
|
111
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks if an absolute path is within any of the allowed directories.
|
|
3
|
+
*
|
|
4
|
+
* @param absolutePath - The absolute path to check (will be normalized)
|
|
5
|
+
* @param allowedDirectories - Array of absolute allowed directory paths (will be normalized)
|
|
6
|
+
* @returns true if the path is within an allowed directory, false otherwise
|
|
7
|
+
* @throws Error if given relative paths after normalization
|
|
8
|
+
*/
|
|
9
|
+
export declare function isPathWithinAllowedDirectories(absolutePath: string, allowedDirectories: string[]): boolean;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isPathWithinAllowedDirectories = isPathWithinAllowedDirectories;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
/**
|
|
9
|
+
* Checks if an absolute path is within any of the allowed directories.
|
|
10
|
+
*
|
|
11
|
+
* @param absolutePath - The absolute path to check (will be normalized)
|
|
12
|
+
* @param allowedDirectories - Array of absolute allowed directory paths (will be normalized)
|
|
13
|
+
* @returns true if the path is within an allowed directory, false otherwise
|
|
14
|
+
* @throws Error if given relative paths after normalization
|
|
15
|
+
*/
|
|
16
|
+
function isPathWithinAllowedDirectories(absolutePath, allowedDirectories) {
|
|
17
|
+
// Type validation
|
|
18
|
+
if (typeof absolutePath !== 'string' || !Array.isArray(allowedDirectories)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
// Reject empty inputs
|
|
22
|
+
if (!absolutePath || allowedDirectories.length === 0) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
// Reject null bytes (forbidden in paths)
|
|
26
|
+
if (absolutePath.includes('\x00')) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
// Normalize the input path
|
|
30
|
+
let normalizedPath;
|
|
31
|
+
try {
|
|
32
|
+
normalizedPath = path_1.default.resolve(path_1.default.normalize(absolutePath));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
// Verify it's absolute after normalization
|
|
38
|
+
if (!path_1.default.isAbsolute(normalizedPath)) {
|
|
39
|
+
throw new Error('Path must be absolute after normalization');
|
|
40
|
+
}
|
|
41
|
+
// Check against each allowed directory
|
|
42
|
+
return allowedDirectories.some(dir => {
|
|
43
|
+
if (typeof dir !== 'string' || !dir) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
// Reject null bytes in allowed dirs
|
|
47
|
+
if (dir.includes('\x00')) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
// Normalize the allowed directory
|
|
51
|
+
let normalizedDir;
|
|
52
|
+
try {
|
|
53
|
+
normalizedDir = path_1.default.resolve(path_1.default.normalize(dir));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
// Verify allowed directory is absolute after normalization
|
|
59
|
+
if (!path_1.default.isAbsolute(normalizedDir)) {
|
|
60
|
+
throw new Error('Allowed directories must be absolute paths after normalization');
|
|
61
|
+
}
|
|
62
|
+
// Check if normalizedPath is within normalizedDir
|
|
63
|
+
// Path is inside if it's the same or a subdirectory
|
|
64
|
+
if (normalizedPath === normalizedDir) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
// Special case for root directory to avoid double slash
|
|
68
|
+
// On Windows, we need to check if both paths are on the same drive
|
|
69
|
+
if (normalizedDir === path_1.default.sep) {
|
|
70
|
+
return normalizedPath.startsWith(path_1.default.sep);
|
|
71
|
+
}
|
|
72
|
+
// On Windows, also check for drive root (e.g., "C:\")
|
|
73
|
+
if (path_1.default.sep === '\\' && normalizedDir.match(/^[A-Za-z]:\\?$/)) {
|
|
74
|
+
// Ensure both paths are on the same drive
|
|
75
|
+
const dirDrive = normalizedDir.charAt(0).toLowerCase();
|
|
76
|
+
const pathDrive = normalizedPath.charAt(0).toLowerCase();
|
|
77
|
+
return pathDrive === dirDrive && normalizedPath.startsWith(normalizedDir.replace(/\\?$/, '\\'));
|
|
78
|
+
}
|
|
79
|
+
return normalizedPath.startsWith(normalizedDir + path_1.default.sep);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Root } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves requested root directories from MCP root specifications.
|
|
4
|
+
*
|
|
5
|
+
* Converts root URI specifications (file:// URIs or plain paths) into normalized
|
|
6
|
+
* directory paths, validating that each path exists and is a directory.
|
|
7
|
+
* Includes symlink resolution for security.
|
|
8
|
+
*
|
|
9
|
+
* @param requestedRoots - Array of root specifications with URI and optional name
|
|
10
|
+
* @returns Promise resolving to array of validated directory paths
|
|
11
|
+
*/
|
|
12
|
+
export declare function getValidRootDirectories(requestedRoots: readonly Root[]): Promise<string[]>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getValidRootDirectories = getValidRootDirectories;
|
|
7
|
+
const fs_1 = require("fs");
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const path_utils_js_1 = require("./path-utils.js");
|
|
11
|
+
/**
|
|
12
|
+
* Converts a root URI to a normalized directory path with basic security validation.
|
|
13
|
+
* @param rootUri - File URI (file://...) or plain directory path
|
|
14
|
+
* @returns Promise resolving to validated path or null if invalid
|
|
15
|
+
*/
|
|
16
|
+
async function parseRootUri(rootUri) {
|
|
17
|
+
try {
|
|
18
|
+
const rawPath = rootUri.startsWith('file://') ? rootUri.slice(7) : rootUri;
|
|
19
|
+
const expandedPath = rawPath.startsWith('~/') || rawPath === '~'
|
|
20
|
+
? path_1.default.join(os_1.default.homedir(), rawPath.slice(1))
|
|
21
|
+
: rawPath;
|
|
22
|
+
const absolutePath = path_1.default.resolve(expandedPath);
|
|
23
|
+
const resolvedPath = await fs_1.promises.realpath(absolutePath);
|
|
24
|
+
return (0, path_utils_js_1.normalizePath)(resolvedPath);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null; // Path doesn't exist or other error
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Formats error message for directory validation failures.
|
|
32
|
+
* @param dir - Directory path that failed validation
|
|
33
|
+
* @param error - Error that occurred during validation
|
|
34
|
+
* @param reason - Specific reason for failure
|
|
35
|
+
* @returns Formatted error message
|
|
36
|
+
*/
|
|
37
|
+
function formatDirectoryError(dir, error, reason) {
|
|
38
|
+
if (reason) {
|
|
39
|
+
return `Skipping ${reason}: ${dir}`;
|
|
40
|
+
}
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
return `Skipping invalid directory: ${dir} due to error: ${message}`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolves requested root directories from MCP root specifications.
|
|
46
|
+
*
|
|
47
|
+
* Converts root URI specifications (file:// URIs or plain paths) into normalized
|
|
48
|
+
* directory paths, validating that each path exists and is a directory.
|
|
49
|
+
* Includes symlink resolution for security.
|
|
50
|
+
*
|
|
51
|
+
* @param requestedRoots - Array of root specifications with URI and optional name
|
|
52
|
+
* @returns Promise resolving to array of validated directory paths
|
|
53
|
+
*/
|
|
54
|
+
async function getValidRootDirectories(requestedRoots) {
|
|
55
|
+
const validatedDirectories = [];
|
|
56
|
+
for (const requestedRoot of requestedRoots) {
|
|
57
|
+
const resolvedPath = await parseRootUri(requestedRoot.uri);
|
|
58
|
+
if (!resolvedPath) {
|
|
59
|
+
console.error(formatDirectoryError(requestedRoot.uri, undefined, 'invalid path or inaccessible'));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const stats = await fs_1.promises.stat(resolvedPath);
|
|
64
|
+
if (stats.isDirectory()) {
|
|
65
|
+
validatedDirectories.push(resolvedPath);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.error(formatDirectoryError(resolvedPath, undefined, 'non-directory root'));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
console.error(formatDirectoryError(resolvedPath, error));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return validatedDirectories;
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const getTsLspTools: (targetDir: string) => import("../../types/type").ToolInfo[];
|