boxsafe 1.0.0
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/.directory +2 -0
- package/.env.example +3 -0
- package/AUDIT_LANG.md +45 -0
- package/BOXSAFE_VERSION_NOTES.md +14 -0
- package/README.md +4 -0
- package/TODO.md +130 -0
- package/adapters/index.ts +27 -0
- package/adapters/primary/cli-adapter.ts +56 -0
- package/adapters/secondary/filesystem/node-filesystem.ts +307 -0
- package/adapters/secondary/system/configuration.ts +147 -0
- package/ai/caller.ts +42 -0
- package/ai/label.ts +33 -0
- package/ai/modelConfig.ts +236 -0
- package/ai/provider.ts +111 -0
- package/boxsafe.config.json +68 -0
- package/core/auth/dasktop/cred/CRED.md +112 -0
- package/core/auth/dasktop/cred/credLinux.ts +82 -0
- package/core/auth/dasktop/cred/credWin.ts +2 -0
- package/core/config/defaults/boxsafeDefaults.ts +67 -0
- package/core/config/defaults/index.ts +1 -0
- package/core/config/loadConfig.ts +133 -0
- package/core/loop/about.md +13 -0
- package/core/loop/boxConfig.ts +20 -0
- package/core/loop/buildExecCommand.ts +76 -0
- package/core/loop/cmd/execode.ts +121 -0
- package/core/loop/cmd/test.js +3 -0
- package/core/loop/execLoop.ts +341 -0
- package/core/loop/git/VERSIONING.md +17 -0
- package/core/loop/git/commands.ts +11 -0
- package/core/loop/git/gitClient.ts +78 -0
- package/core/loop/git/index.ts +99 -0
- package/core/loop/git/runVersionControlRunner.ts +33 -0
- package/core/loop/initNavigator.ts +44 -0
- package/core/loop/initTasksManager.ts +35 -0
- package/core/loop/runValidation.ts +25 -0
- package/core/loop/tasks/AGENT-TASKS.md +36 -0
- package/core/loop/tasks/index.ts +96 -0
- package/core/loop/toolCalls.ts +168 -0
- package/core/loop/toolDispatcher.ts +146 -0
- package/core/loop/traceLogger.ts +106 -0
- package/core/loop/types.ts +26 -0
- package/core/loop/versionControlAdapter.ts +36 -0
- package/core/loop/waterfall.ts +404 -0
- package/core/loop/writeArtifactAtomically.ts +13 -0
- package/core/navigate/NAVIGATE.md +186 -0
- package/core/navigate/about.md +128 -0
- package/core/navigate/examples.ts +367 -0
- package/core/navigate/handler.ts +148 -0
- package/core/navigate/index.ts +32 -0
- package/core/navigate/navigate.test.ts +372 -0
- package/core/navigate/navigator.ts +437 -0
- package/core/navigate/types.ts +132 -0
- package/core/navigate/utils.ts +146 -0
- package/core/paths/paths.ts +33 -0
- package/core/ports/index.ts +271 -0
- package/core/segments/CONVENTIONS.md +30 -0
- package/core/segments/loop/index.ts +18 -0
- package/core/segments/map.ts +56 -0
- package/core/segments/navigate/index.ts +20 -0
- package/core/segments/versionControl/index.ts +18 -0
- package/core/util/logger.ts +128 -0
- package/docs/AGENT-TASKS.md +36 -0
- package/docs/ARQUITETURA_CORRECAO.md +121 -0
- package/docs/CONVENTIONS.md +30 -0
- package/docs/CRED.md +112 -0
- package/docs/L_RAG.md +567 -0
- package/docs/NAVIGATE.md +186 -0
- package/docs/PRIMARY_ACTORS.md +78 -0
- package/docs/SECONDARY_ACTORS.md +174 -0
- package/docs/VERSIONING.md +17 -0
- package/docs/boxsafe.config.md +472 -0
- package/eslint.config.mts +15 -0
- package/main.ts +53 -0
- package/memo/generated/codelog.md +13 -0
- package/memo/state/tasks/state.json +6 -0
- package/memo/state/tasks/tasks/task_001.md +2 -0
- package/memo/states-logs/logs.txt +7 -0
- package/memo/states-logs/trace-mljvrxvi-9g0k4q.jsonl +11 -0
- package/memo/states-logs/trace-mljvvc9j-pe9ekj.jsonl +11 -0
- package/memo/states-logs/trace-mljvvm1c-wbnqzp.jsonl +11 -0
- package/memo/states-logs/trace-mljxecwn-9xh3nw.jsonl +11 -0
- package/memo/states-logs/trace-mljxqkfm-ipijik.jsonl +11 -0
- package/memo/states-logs/trace-mljxwtrw-3fanky.jsonl +11 -0
- package/memo/states-logs/trace-mljxzen3-m8iinh.jsonl +11 -0
- package/memo/states-logs/trace-mljyucef-td6odn.jsonl +11 -0
- package/memo/states-logs/trace-mljyuprw-b1a6f4.jsonl +11 -0
- package/memo/states-logs/trace-mljyvefl-b6yoce.jsonl +11 -0
- package/memo/states-logs/trace-mljyxjo4-n7ibj2.jsonl +13 -0
- package/memo/states-logs/trace-mljziez5-8drqtn.jsonl +13 -0
- package/memo/states-logs/trace-mljziulp-dtd03z.jsonl +13 -0
- package/memo/states-logs/trace-mljzjwrq-1p2krb.jsonl +13 -0
- package/memo/states-logs/trace-mljzl0i7-b1cqa6.jsonl +13 -0
- package/memo/states-logs/trace-mljzmlk6-7kdyls.jsonl +13 -0
- package/memo/states-logs/trace-mlk0oj25-xa3dcu.jsonl +13 -0
- package/memo/states-logs/trace-mlk1x59q-713huj.jsonl +14 -0
- package/memo/states-logs/trace-mlk22dz8-7fd6hq.jsonl +14 -0
- package/memo/states-logs/trace-mlk241uy-wmx907.jsonl +14 -0
- package/memo/states-logs/trace-mlk2bf5r-yoh1vg.jsonl +15 -0
- package/package.json +44 -0
- package/pnpm-workspace.yaml +4 -0
- package/prompt_improvement_example.md +55 -0
- package/remove.txt +1 -0
- package/tests/adapters.test.ts +128 -0
- package/tests/extractCode.test.ts +26 -0
- package/tests/integration.test.ts +83 -0
- package/tests/loadConfig.test.ts +25 -0
- package/tests/navigatorBoundary.test.ts +17 -0
- package/tests/ports.test.ts +84 -0
- package/tests/runAllTests.ts +49 -0
- package/tests/toolCalls.test.ts +149 -0
- package/tests/waterfall.test.ts +52 -0
- package/tsconfig.json +32 -0
- package/tsup.config.ts +17 -0
- package/types.d.ts +96 -0
- package/util/ANSI.ts +29 -0
- package/util/extractCode.ts +217 -0
- package/util/extractToolCalls.ts +80 -0
- package/util/logger.ts +125 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview
|
|
3
|
+
* Main file system navigator class for LLM-driven file operations.
|
|
4
|
+
* Provides safe, validated methods for directory and file manipulation.
|
|
5
|
+
*
|
|
6
|
+
* @description
|
|
7
|
+
* The Navigator class enforces workspace boundaries, validates operations,
|
|
8
|
+
* and returns structured results suitable for LLM consumption and iteration.
|
|
9
|
+
*
|
|
10
|
+
* All paths are automatically validated and normalized.
|
|
11
|
+
* Operations respect file size limits to prevent memory issues.
|
|
12
|
+
*
|
|
13
|
+
* @module core/navigate/navigator
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import fsSync from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import type {
|
|
20
|
+
NavigatorConfig,
|
|
21
|
+
NavigatorResult,
|
|
22
|
+
DirectoryListing,
|
|
23
|
+
FileReadResult,
|
|
24
|
+
FileWriteResult,
|
|
25
|
+
DirectoryCreateResult,
|
|
26
|
+
DeleteResult,
|
|
27
|
+
MetadataResult,
|
|
28
|
+
OperationError,
|
|
29
|
+
FileSystemEntry,
|
|
30
|
+
} from '@core/navigate/types';
|
|
31
|
+
import { Logger } from '@core/util/logger';
|
|
32
|
+
import {
|
|
33
|
+
isWithinWorkspace,
|
|
34
|
+
resolvePath,
|
|
35
|
+
isReadable,
|
|
36
|
+
isWritable,
|
|
37
|
+
checkFileSize,
|
|
38
|
+
sanitizeFilename,
|
|
39
|
+
formatPathDisplay,
|
|
40
|
+
} from '@core/navigate/utils';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* File system navigator for safe LLM-driven file operations.
|
|
44
|
+
* Enforces workspace boundaries and validates all operations.
|
|
45
|
+
*/
|
|
46
|
+
export class Navigator {
|
|
47
|
+
private workspace: string;
|
|
48
|
+
private followSymlinks: boolean;
|
|
49
|
+
private maxFileSize: number;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a new Navigator instance.
|
|
53
|
+
*
|
|
54
|
+
* @param config - Configuration options
|
|
55
|
+
* @throws Error if workspace doesn't exist or is invalid
|
|
56
|
+
*/
|
|
57
|
+
constructor(config: NavigatorConfig) {
|
|
58
|
+
// Validate workspace directory exists
|
|
59
|
+
const stats = fsSync.statSync(config.workspace);
|
|
60
|
+
if (!stats.isDirectory()) {
|
|
61
|
+
throw new Error(`Workspace path is not a directory: ${config.workspace}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.workspace = path.resolve(config.workspace); // -- absolute path
|
|
65
|
+
this.followSymlinks = config.followSymlinks ?? false;
|
|
66
|
+
this.maxFileSize = config.maxFileSize ?? 10 * 1024 * 1024; // 10MB default
|
|
67
|
+
|
|
68
|
+
// Create logger directly
|
|
69
|
+
const logger = Logger.createModuleLogger('Navigator');
|
|
70
|
+
logger.debug(`Initialized with workspace: ${this.workspace}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lists the contents of a directory.
|
|
75
|
+
*
|
|
76
|
+
* @param dirPath - Path to the directory (absolute or relative to workspace)
|
|
77
|
+
* @returns Structured result with directory contents or error
|
|
78
|
+
*/
|
|
79
|
+
async listDirectory(dirPath: string = '.'): Promise<NavigatorResult> {
|
|
80
|
+
try {
|
|
81
|
+
// Resolve and validate path
|
|
82
|
+
const pathResult = resolvePath(dirPath, this.workspace);
|
|
83
|
+
if (!pathResult.ok) {
|
|
84
|
+
return this.error('listDirectory', pathResult.error);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const targetPath = pathResult.path;
|
|
88
|
+
|
|
89
|
+
// Verify it's a directory
|
|
90
|
+
const stats = await fs.stat(targetPath);
|
|
91
|
+
if (!stats.isDirectory()) {
|
|
92
|
+
return this.error('listDirectory', 'Path is not a directory');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Read directory contents
|
|
96
|
+
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
|
97
|
+
|
|
98
|
+
// Build file system entries with metadata
|
|
99
|
+
const result: FileSystemEntry[] = [];
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
try {
|
|
102
|
+
const fullPath = path.join(targetPath, entry.name);
|
|
103
|
+
const entryStats = await fs.stat(fullPath);
|
|
104
|
+
|
|
105
|
+
const fsEntry: FileSystemEntry = {
|
|
106
|
+
path: fullPath,
|
|
107
|
+
name: entry.name,
|
|
108
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
109
|
+
mtime: entryStats.mtimeMs,
|
|
110
|
+
readable: isReadable(fullPath),
|
|
111
|
+
writable: isWritable(fullPath),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Only add size for files, not directories
|
|
115
|
+
if (!entry.isDirectory()) {
|
|
116
|
+
fsEntry.size = entryStats.size;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
result.push(fsEntry);
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
Logger.createModuleLogger('Navigator').warn(`Failed to stat entry ${entry.name}: ${err?.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Sort: directories first, then alphabetically
|
|
126
|
+
result.sort((a, b) => {
|
|
127
|
+
if (a.type !== b.type) {
|
|
128
|
+
return a.type === 'directory' ? -1 : 1;
|
|
129
|
+
}
|
|
130
|
+
return a.name.localeCompare(b.name);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
path: formatPathDisplay(targetPath, this.workspace),
|
|
136
|
+
entries: result,
|
|
137
|
+
total: result.length,
|
|
138
|
+
} as DirectoryListing;
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
return this.error(
|
|
141
|
+
'listDirectory',
|
|
142
|
+
`Failed to list directory: ${err?.message ?? 'unknown error'}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reads the content of a file.
|
|
149
|
+
*
|
|
150
|
+
* @param filePath - Path to the file (absolute or relative to workspace)
|
|
151
|
+
* @returns Structured result with file content or error
|
|
152
|
+
*/
|
|
153
|
+
async readFile(filePath: string): Promise<NavigatorResult> {
|
|
154
|
+
try {
|
|
155
|
+
// Resolve and validate path
|
|
156
|
+
const pathResult = resolvePath(filePath, this.workspace);
|
|
157
|
+
if (!pathResult.ok) {
|
|
158
|
+
return this.error('readFile', pathResult.error);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const targetPath = pathResult.path;
|
|
162
|
+
|
|
163
|
+
// Verify it's a file
|
|
164
|
+
const stats = await fs.stat(targetPath);
|
|
165
|
+
if (stats.isDirectory()) {
|
|
166
|
+
return this.error('readFile', 'Path is a directory, not a file');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!isReadable(targetPath)) {
|
|
170
|
+
return this.error('readFile', 'File is not readable (permission denied)');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check file size
|
|
174
|
+
const sizeCheck = checkFileSize(targetPath, this.maxFileSize);
|
|
175
|
+
if (!sizeCheck.ok) {
|
|
176
|
+
return this.error('readFile', sizeCheck.error);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Read file content
|
|
180
|
+
const content = await fs.readFile(targetPath, 'utf-8');
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
path: formatPathDisplay(targetPath, this.workspace),
|
|
185
|
+
content,
|
|
186
|
+
size: sizeCheck.size,
|
|
187
|
+
encoding: 'utf-8',
|
|
188
|
+
} as FileReadResult;
|
|
189
|
+
} catch (err: any) {
|
|
190
|
+
return this.error(
|
|
191
|
+
'readFile',
|
|
192
|
+
`Failed to read file: ${err?.message ?? 'unknown error'}`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Writes content to a file.
|
|
199
|
+
* Creates the file if it doesn't exist, overwrites if it does.
|
|
200
|
+
*
|
|
201
|
+
* @param filePath - Path to the file (absolute or relative to workspace)
|
|
202
|
+
* @param content - Content to write
|
|
203
|
+
* @param options - Write options
|
|
204
|
+
* @returns Structured result with operation details or error
|
|
205
|
+
*/
|
|
206
|
+
async writeFile(
|
|
207
|
+
filePath: string,
|
|
208
|
+
content: string,
|
|
209
|
+
options?: { append?: boolean; createDirs?: boolean }
|
|
210
|
+
): Promise<NavigatorResult> {
|
|
211
|
+
try {
|
|
212
|
+
// Resolve and validate path
|
|
213
|
+
const pathResult = resolvePath(filePath, this.workspace);
|
|
214
|
+
if (!pathResult.ok) {
|
|
215
|
+
return this.error('writeFile', pathResult.error);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const targetPath = pathResult.path;
|
|
219
|
+
|
|
220
|
+
// Check if file exists and is writable
|
|
221
|
+
const fileExists = fsSync.existsSync(targetPath);
|
|
222
|
+
if (fileExists) {
|
|
223
|
+
const stats = fsSync.statSync(targetPath);
|
|
224
|
+
if (stats.isDirectory()) {
|
|
225
|
+
return this.error('writeFile', 'Path is a directory, not a file');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!isWritable(targetPath)) {
|
|
229
|
+
return this.error(
|
|
230
|
+
'writeFile',
|
|
231
|
+
'File exists but is not writable (permission denied)'
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
} else if (!options?.createDirs && !fsSync.existsSync(path.dirname(targetPath))) {
|
|
235
|
+
return this.error(
|
|
236
|
+
'writeFile',
|
|
237
|
+
'Parent directory does not exist (use createDirs: true option)'
|
|
238
|
+
);
|
|
239
|
+
} else {
|
|
240
|
+
const dirPath = path.dirname(targetPath);
|
|
241
|
+
if (!fsSync.existsSync(dirPath)) {
|
|
242
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Write file
|
|
247
|
+
if (options?.append && fileExists) {
|
|
248
|
+
await fs.appendFile(targetPath, content, 'utf-8');
|
|
249
|
+
} else {
|
|
250
|
+
await fs.writeFile(targetPath, content, 'utf-8');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Get final size
|
|
254
|
+
const finalStats = await fs.stat(targetPath);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
path: formatPathDisplay(targetPath, this.workspace),
|
|
259
|
+
size: finalStats.size,
|
|
260
|
+
created: !fileExists,
|
|
261
|
+
} as FileWriteResult;
|
|
262
|
+
} catch (err: any) {
|
|
263
|
+
return this.error(
|
|
264
|
+
'writeFile',
|
|
265
|
+
`Failed to write file: ${err?.message ?? 'unknown error'}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Creates a new directory.
|
|
272
|
+
*
|
|
273
|
+
* @param dirPath - Path to the directory to create (absolute or relative to workspace)
|
|
274
|
+
* @param options - Creation options
|
|
275
|
+
* @returns Structured result with operation details or error
|
|
276
|
+
*/
|
|
277
|
+
async createDirectory(
|
|
278
|
+
dirPath: string,
|
|
279
|
+
options?: { recursive?: boolean }
|
|
280
|
+
): Promise<NavigatorResult> {
|
|
281
|
+
try {
|
|
282
|
+
// Resolve and validate path
|
|
283
|
+
const pathResult = resolvePath(dirPath, this.workspace);
|
|
284
|
+
if (!pathResult.ok) {
|
|
285
|
+
return this.error('createDirectory', pathResult.error);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const targetPath = pathResult.path;
|
|
289
|
+
|
|
290
|
+
// Check if directory already exists
|
|
291
|
+
if (fsSync.existsSync(targetPath)) {
|
|
292
|
+
const stats = fsSync.statSync(targetPath);
|
|
293
|
+
if (!stats.isDirectory()) {
|
|
294
|
+
return this.error('createDirectory', 'Path exists but is not a directory');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
path: formatPathDisplay(targetPath, this.workspace),
|
|
300
|
+
created: false,
|
|
301
|
+
} as DirectoryCreateResult;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Create directory
|
|
305
|
+
await fs.mkdir(targetPath, { recursive: options?.recursive ?? true });
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
ok: true,
|
|
309
|
+
path: formatPathDisplay(targetPath, this.workspace),
|
|
310
|
+
created: true,
|
|
311
|
+
} as DirectoryCreateResult;
|
|
312
|
+
} catch (err: any) {
|
|
313
|
+
return this.error(
|
|
314
|
+
'createDirectory',
|
|
315
|
+
`Failed to create directory: ${err?.message ?? 'unknown error'}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Deletes a file or directory.
|
|
322
|
+
*
|
|
323
|
+
* @param targetPath - Path to delete (absolute or relative to workspace)
|
|
324
|
+
* @param options - Deletion options
|
|
325
|
+
* @returns Structured result with operation details or error
|
|
326
|
+
*/
|
|
327
|
+
async delete(
|
|
328
|
+
targetPath: string,
|
|
329
|
+
options?: { recursive?: boolean }
|
|
330
|
+
): Promise<NavigatorResult> {
|
|
331
|
+
try {
|
|
332
|
+
// Resolve and validate path
|
|
333
|
+
const pathResult = resolvePath(targetPath, this.workspace);
|
|
334
|
+
if (!pathResult.ok) {
|
|
335
|
+
return this.error('delete', pathResult.error);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const resolvedPath = pathResult.path;
|
|
339
|
+
|
|
340
|
+
// Check if path exists
|
|
341
|
+
if (!fsSync.existsSync(resolvedPath)) {
|
|
342
|
+
return this.error('delete', 'Path does not exist');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const stats = fsSync.statSync(resolvedPath);
|
|
346
|
+
const isDirectory = stats.isDirectory();
|
|
347
|
+
|
|
348
|
+
// Delete based on type
|
|
349
|
+
if (isDirectory) {
|
|
350
|
+
if (options?.recursive ?? true) {
|
|
351
|
+
await fs.rm(resolvedPath, { recursive: true, force: true });
|
|
352
|
+
} else {
|
|
353
|
+
await fs.rmdir(resolvedPath);
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
await fs.unlink(resolvedPath);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
ok: true,
|
|
361
|
+
path: formatPathDisplay(resolvedPath, this.workspace),
|
|
362
|
+
type: isDirectory ? 'directory' : 'file',
|
|
363
|
+
deletedAt: Date.now(),
|
|
364
|
+
} as DeleteResult;
|
|
365
|
+
} catch (err: any) {
|
|
366
|
+
return this.error(
|
|
367
|
+
'delete',
|
|
368
|
+
`Failed to delete: ${err?.message ?? 'unknown error'}`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Gets metadata about a file or directory.
|
|
375
|
+
*
|
|
376
|
+
* @param targetPath - Path to get metadata for (absolute or relative to workspace)
|
|
377
|
+
* @returns Structured result with metadata or error
|
|
378
|
+
*/
|
|
379
|
+
async getMetadata(targetPath: string): Promise<NavigatorResult> {
|
|
380
|
+
try {
|
|
381
|
+
// Resolve and validate path
|
|
382
|
+
const pathResult = resolvePath(targetPath, this.workspace);
|
|
383
|
+
if (!pathResult.ok) {
|
|
384
|
+
return this.error('getMetadata', pathResult.error);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const resolvedPath = pathResult.path;
|
|
388
|
+
|
|
389
|
+
// Check if path exists
|
|
390
|
+
if (!fsSync.existsSync(resolvedPath)) {
|
|
391
|
+
return this.error('getMetadata', 'Path does not exist');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const stats = await fs.stat(resolvedPath);
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
path: formatPathDisplay(resolvedPath, this.workspace),
|
|
399
|
+
stat: {
|
|
400
|
+
type: stats.isDirectory() ? 'directory' : 'file',
|
|
401
|
+
size: stats.size,
|
|
402
|
+
mtime: stats.mtimeMs,
|
|
403
|
+
isReadable: isReadable(resolvedPath),
|
|
404
|
+
isWritable: isWritable(resolvedPath),
|
|
405
|
+
},
|
|
406
|
+
} as MetadataResult;
|
|
407
|
+
} catch (err: any) {
|
|
408
|
+
return this.error(
|
|
409
|
+
'getMetadata',
|
|
410
|
+
`Failed to get metadata: ${err?.message ?? 'unknown error'}`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Helper method to create error results.
|
|
417
|
+
* @private
|
|
418
|
+
*/
|
|
419
|
+
private error(operation: string, error: string): OperationError {
|
|
420
|
+
Logger.createModuleLogger('Navigator').debug(`${operation} failed: ${error}`);
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
operation,
|
|
424
|
+
error,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Creates a new Navigator instance with the given configuration.
|
|
431
|
+
*
|
|
432
|
+
* @param config - Configuration options
|
|
433
|
+
* @returns Navigator instance
|
|
434
|
+
*/
|
|
435
|
+
export function createNavigator(config: NavigatorConfig): Navigator {
|
|
436
|
+
return new Navigator(config);
|
|
437
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview
|
|
3
|
+
* Type definitions for the file system navigation module.
|
|
4
|
+
* Provides structured interfaces for file operations and results.
|
|
5
|
+
*
|
|
6
|
+
* @module core/navigate/types
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Represents a file system entry (file or directory).
|
|
11
|
+
*/
|
|
12
|
+
export interface FileSystemEntry {
|
|
13
|
+
/** Absolute path to the entry */
|
|
14
|
+
path: string;
|
|
15
|
+
/** Name of the entry (filename or dirname) */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Type of entry: 'file' or 'directory' */
|
|
18
|
+
type: 'file' | 'directory';
|
|
19
|
+
/** Size in bytes (files only) */
|
|
20
|
+
size?: number;
|
|
21
|
+
/** Unix timestamp of last modification */
|
|
22
|
+
mtime?: number;
|
|
23
|
+
/** Whether the path is readable */
|
|
24
|
+
readable: boolean;
|
|
25
|
+
/** Whether the path is writable */
|
|
26
|
+
writable: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Result of listing directory contents.
|
|
31
|
+
*/
|
|
32
|
+
export interface DirectoryListing {
|
|
33
|
+
ok: true;
|
|
34
|
+
path: string;
|
|
35
|
+
entries: FileSystemEntry[];
|
|
36
|
+
total: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Result of reading a file.
|
|
41
|
+
*/
|
|
42
|
+
export interface FileReadResult {
|
|
43
|
+
ok: true;
|
|
44
|
+
path: string;
|
|
45
|
+
content: string;
|
|
46
|
+
size: number;
|
|
47
|
+
encoding: 'utf-8';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Result of writing a file.
|
|
52
|
+
*/
|
|
53
|
+
export interface FileWriteResult {
|
|
54
|
+
ok: true;
|
|
55
|
+
path: string;
|
|
56
|
+
size: number;
|
|
57
|
+
created: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Result of creating a directory.
|
|
62
|
+
*/
|
|
63
|
+
export interface DirectoryCreateResult {
|
|
64
|
+
ok: true;
|
|
65
|
+
path: string;
|
|
66
|
+
created: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Result of deleting a file or directory.
|
|
71
|
+
*/
|
|
72
|
+
export interface DeleteResult {
|
|
73
|
+
ok: true;
|
|
74
|
+
path: string;
|
|
75
|
+
type: 'file' | 'directory';
|
|
76
|
+
deletedAt: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Result of getting metadata.
|
|
81
|
+
*/
|
|
82
|
+
export interface MetadataResult {
|
|
83
|
+
ok: true;
|
|
84
|
+
path: string;
|
|
85
|
+
stat: {
|
|
86
|
+
type: 'file' | 'directory';
|
|
87
|
+
size: number;
|
|
88
|
+
mtime: number;
|
|
89
|
+
isReadable: boolean;
|
|
90
|
+
isWritable: boolean;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Unified error result for any operation failure.
|
|
96
|
+
*/
|
|
97
|
+
export interface OperationError {
|
|
98
|
+
ok: false;
|
|
99
|
+
operation: string;
|
|
100
|
+
error: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Union of all possible successful operation results */
|
|
104
|
+
export type OperationResult =
|
|
105
|
+
| DirectoryListing
|
|
106
|
+
| FileReadResult
|
|
107
|
+
| FileWriteResult
|
|
108
|
+
| DirectoryCreateResult
|
|
109
|
+
| DeleteResult
|
|
110
|
+
| MetadataResult;
|
|
111
|
+
|
|
112
|
+
/** Union of all possible results (success or error) */
|
|
113
|
+
export type NavigatorResult = OperationResult | OperationError;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Options for navigator initialization.
|
|
117
|
+
*/
|
|
118
|
+
export interface NavigatorConfig {
|
|
119
|
+
/** Workspace root path - all operations must stay within this boundary */
|
|
120
|
+
workspace: string;
|
|
121
|
+
/** Whether to follow symbolic links (for security) */
|
|
122
|
+
followSymlinks?: boolean;
|
|
123
|
+
/** Maximum file size to read in bytes (prevents memory issues) */
|
|
124
|
+
maxFileSize?: number;
|
|
125
|
+
/** Logger instance for debugging */
|
|
126
|
+
logger?: {
|
|
127
|
+
debug: (...args: any[]) => void;
|
|
128
|
+
info: (...args: any[]) => void;
|
|
129
|
+
warn: (...args: any[]) => void;
|
|
130
|
+
error: (...args: any[]) => void;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview
|
|
3
|
+
* Utility functions for path validation and security checks.
|
|
4
|
+
*
|
|
5
|
+
* @module core/navigate/utils
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { accessSync, constants } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validates that a given path is within the workspace boundary.
|
|
14
|
+
* Prevents directory traversal attacks and unauthorized access.
|
|
15
|
+
*
|
|
16
|
+
* @param targetPath - The path to validate
|
|
17
|
+
* @param workspace - The workspace root path
|
|
18
|
+
* @returns true if path is within workspace, false otherwise
|
|
19
|
+
*/
|
|
20
|
+
export function isWithinWorkspace(targetPath: string, workspace: string): boolean {
|
|
21
|
+
try {
|
|
22
|
+
const absTarget = path.resolve(targetPath);
|
|
23
|
+
const absWorkspace = path.resolve(workspace);
|
|
24
|
+
const relative = path.relative(absWorkspace, absTarget);
|
|
25
|
+
|
|
26
|
+
// If relative path starts with '..', it's outside workspace
|
|
27
|
+
return !relative.startsWith('..');
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalizes and resolves a path relative to workspace.
|
|
35
|
+
* Handles both absolute and relative paths.
|
|
36
|
+
*
|
|
37
|
+
* @param inputPath - The path to normalize
|
|
38
|
+
* @param workspace - The workspace root path
|
|
39
|
+
* @returns Absolute resolved path, or error message
|
|
40
|
+
*/
|
|
41
|
+
export function resolvePath(inputPath: string, workspace: string): { ok: true; path: string } | { ok: false; error: string } {
|
|
42
|
+
try {
|
|
43
|
+
const resolved = path.isAbsolute(inputPath)
|
|
44
|
+
? path.resolve(inputPath)
|
|
45
|
+
: path.resolve(workspace, inputPath);
|
|
46
|
+
|
|
47
|
+
if (!isWithinWorkspace(resolved, workspace)) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: `Access denied: path outside workspace boundary (${resolved})`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { ok: true, path: resolved };
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: `Failed to resolve path: ${err?.message ?? 'unknown error'}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Checks if a path is readable.
|
|
65
|
+
* Safely handles permission errors.
|
|
66
|
+
*
|
|
67
|
+
* @param filePath - The path to check
|
|
68
|
+
* @returns true if readable, false otherwise
|
|
69
|
+
*/
|
|
70
|
+
export function isReadable(filePath: string): boolean {
|
|
71
|
+
try {
|
|
72
|
+
accessSync(filePath, constants.R_OK);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Checks if a path is writable.
|
|
81
|
+
* Safely handles permission errors.
|
|
82
|
+
*
|
|
83
|
+
* @param filePath - The path to check
|
|
84
|
+
* @returns true if writable, false otherwise
|
|
85
|
+
*/
|
|
86
|
+
export function isWritable(filePath: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
accessSync(filePath, constants.W_OK);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Checks if a file size exceeds the maximum allowed.
|
|
97
|
+
*
|
|
98
|
+
* @param filePath - The path to check
|
|
99
|
+
* @param maxSize - Maximum allowed size in bytes
|
|
100
|
+
* @returns { ok: true; size: number } or { ok: false; error: string }
|
|
101
|
+
*/
|
|
102
|
+
export function checkFileSize(
|
|
103
|
+
filePath: string,
|
|
104
|
+
maxSize: number
|
|
105
|
+
): { ok: true; size: number } | { ok: false; error: string } {
|
|
106
|
+
try {
|
|
107
|
+
const stats = fs.statSync(filePath);
|
|
108
|
+
if (stats.size > maxSize) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: `File size exceeds limit: ${stats.size} bytes > ${maxSize} bytes`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, size: stats.size };
|
|
115
|
+
} catch (err: any) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: `Failed to check file size: ${err?.message ?? 'unknown'}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Sanitizes a filename to prevent directory traversal in filenames.
|
|
125
|
+
*
|
|
126
|
+
* @param filename - The filename to sanitize
|
|
127
|
+
* @returns Sanitized filename
|
|
128
|
+
*/
|
|
129
|
+
export function sanitizeFilename(filename: string): string {
|
|
130
|
+
return filename
|
|
131
|
+
.replace(/[/\\]/g, '_') // Remove path separators
|
|
132
|
+
.replace(/^\.|\.$/g, '') // Remove leading/trailing dots
|
|
133
|
+
.replace(/^-/, '_'); // Remove leading dash
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Formats a path for display (makes it relative to workspace).
|
|
138
|
+
*
|
|
139
|
+
* @param absolutePath - The absolute path
|
|
140
|
+
* @param workspace - The workspace root
|
|
141
|
+
* @returns Display-friendly path
|
|
142
|
+
*/
|
|
143
|
+
export function formatPathDisplay(absolutePath: string, workspace: string): string {
|
|
144
|
+
const relative = path.relative(workspace, absolutePath);
|
|
145
|
+
return relative || '.';
|
|
146
|
+
}
|