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.
Files changed (118) hide show
  1. package/.directory +2 -0
  2. package/.env.example +3 -0
  3. package/AUDIT_LANG.md +45 -0
  4. package/BOXSAFE_VERSION_NOTES.md +14 -0
  5. package/README.md +4 -0
  6. package/TODO.md +130 -0
  7. package/adapters/index.ts +27 -0
  8. package/adapters/primary/cli-adapter.ts +56 -0
  9. package/adapters/secondary/filesystem/node-filesystem.ts +307 -0
  10. package/adapters/secondary/system/configuration.ts +147 -0
  11. package/ai/caller.ts +42 -0
  12. package/ai/label.ts +33 -0
  13. package/ai/modelConfig.ts +236 -0
  14. package/ai/provider.ts +111 -0
  15. package/boxsafe.config.json +68 -0
  16. package/core/auth/dasktop/cred/CRED.md +112 -0
  17. package/core/auth/dasktop/cred/credLinux.ts +82 -0
  18. package/core/auth/dasktop/cred/credWin.ts +2 -0
  19. package/core/config/defaults/boxsafeDefaults.ts +67 -0
  20. package/core/config/defaults/index.ts +1 -0
  21. package/core/config/loadConfig.ts +133 -0
  22. package/core/loop/about.md +13 -0
  23. package/core/loop/boxConfig.ts +20 -0
  24. package/core/loop/buildExecCommand.ts +76 -0
  25. package/core/loop/cmd/execode.ts +121 -0
  26. package/core/loop/cmd/test.js +3 -0
  27. package/core/loop/execLoop.ts +341 -0
  28. package/core/loop/git/VERSIONING.md +17 -0
  29. package/core/loop/git/commands.ts +11 -0
  30. package/core/loop/git/gitClient.ts +78 -0
  31. package/core/loop/git/index.ts +99 -0
  32. package/core/loop/git/runVersionControlRunner.ts +33 -0
  33. package/core/loop/initNavigator.ts +44 -0
  34. package/core/loop/initTasksManager.ts +35 -0
  35. package/core/loop/runValidation.ts +25 -0
  36. package/core/loop/tasks/AGENT-TASKS.md +36 -0
  37. package/core/loop/tasks/index.ts +96 -0
  38. package/core/loop/toolCalls.ts +168 -0
  39. package/core/loop/toolDispatcher.ts +146 -0
  40. package/core/loop/traceLogger.ts +106 -0
  41. package/core/loop/types.ts +26 -0
  42. package/core/loop/versionControlAdapter.ts +36 -0
  43. package/core/loop/waterfall.ts +404 -0
  44. package/core/loop/writeArtifactAtomically.ts +13 -0
  45. package/core/navigate/NAVIGATE.md +186 -0
  46. package/core/navigate/about.md +128 -0
  47. package/core/navigate/examples.ts +367 -0
  48. package/core/navigate/handler.ts +148 -0
  49. package/core/navigate/index.ts +32 -0
  50. package/core/navigate/navigate.test.ts +372 -0
  51. package/core/navigate/navigator.ts +437 -0
  52. package/core/navigate/types.ts +132 -0
  53. package/core/navigate/utils.ts +146 -0
  54. package/core/paths/paths.ts +33 -0
  55. package/core/ports/index.ts +271 -0
  56. package/core/segments/CONVENTIONS.md +30 -0
  57. package/core/segments/loop/index.ts +18 -0
  58. package/core/segments/map.ts +56 -0
  59. package/core/segments/navigate/index.ts +20 -0
  60. package/core/segments/versionControl/index.ts +18 -0
  61. package/core/util/logger.ts +128 -0
  62. package/docs/AGENT-TASKS.md +36 -0
  63. package/docs/ARQUITETURA_CORRECAO.md +121 -0
  64. package/docs/CONVENTIONS.md +30 -0
  65. package/docs/CRED.md +112 -0
  66. package/docs/L_RAG.md +567 -0
  67. package/docs/NAVIGATE.md +186 -0
  68. package/docs/PRIMARY_ACTORS.md +78 -0
  69. package/docs/SECONDARY_ACTORS.md +174 -0
  70. package/docs/VERSIONING.md +17 -0
  71. package/docs/boxsafe.config.md +472 -0
  72. package/eslint.config.mts +15 -0
  73. package/main.ts +53 -0
  74. package/memo/generated/codelog.md +13 -0
  75. package/memo/state/tasks/state.json +6 -0
  76. package/memo/state/tasks/tasks/task_001.md +2 -0
  77. package/memo/states-logs/logs.txt +7 -0
  78. package/memo/states-logs/trace-mljvrxvi-9g0k4q.jsonl +11 -0
  79. package/memo/states-logs/trace-mljvvc9j-pe9ekj.jsonl +11 -0
  80. package/memo/states-logs/trace-mljvvm1c-wbnqzp.jsonl +11 -0
  81. package/memo/states-logs/trace-mljxecwn-9xh3nw.jsonl +11 -0
  82. package/memo/states-logs/trace-mljxqkfm-ipijik.jsonl +11 -0
  83. package/memo/states-logs/trace-mljxwtrw-3fanky.jsonl +11 -0
  84. package/memo/states-logs/trace-mljxzen3-m8iinh.jsonl +11 -0
  85. package/memo/states-logs/trace-mljyucef-td6odn.jsonl +11 -0
  86. package/memo/states-logs/trace-mljyuprw-b1a6f4.jsonl +11 -0
  87. package/memo/states-logs/trace-mljyvefl-b6yoce.jsonl +11 -0
  88. package/memo/states-logs/trace-mljyxjo4-n7ibj2.jsonl +13 -0
  89. package/memo/states-logs/trace-mljziez5-8drqtn.jsonl +13 -0
  90. package/memo/states-logs/trace-mljziulp-dtd03z.jsonl +13 -0
  91. package/memo/states-logs/trace-mljzjwrq-1p2krb.jsonl +13 -0
  92. package/memo/states-logs/trace-mljzl0i7-b1cqa6.jsonl +13 -0
  93. package/memo/states-logs/trace-mljzmlk6-7kdyls.jsonl +13 -0
  94. package/memo/states-logs/trace-mlk0oj25-xa3dcu.jsonl +13 -0
  95. package/memo/states-logs/trace-mlk1x59q-713huj.jsonl +14 -0
  96. package/memo/states-logs/trace-mlk22dz8-7fd6hq.jsonl +14 -0
  97. package/memo/states-logs/trace-mlk241uy-wmx907.jsonl +14 -0
  98. package/memo/states-logs/trace-mlk2bf5r-yoh1vg.jsonl +15 -0
  99. package/package.json +44 -0
  100. package/pnpm-workspace.yaml +4 -0
  101. package/prompt_improvement_example.md +55 -0
  102. package/remove.txt +1 -0
  103. package/tests/adapters.test.ts +128 -0
  104. package/tests/extractCode.test.ts +26 -0
  105. package/tests/integration.test.ts +83 -0
  106. package/tests/loadConfig.test.ts +25 -0
  107. package/tests/navigatorBoundary.test.ts +17 -0
  108. package/tests/ports.test.ts +84 -0
  109. package/tests/runAllTests.ts +49 -0
  110. package/tests/toolCalls.test.ts +149 -0
  111. package/tests/waterfall.test.ts +52 -0
  112. package/tsconfig.json +32 -0
  113. package/tsup.config.ts +17 -0
  114. package/types.d.ts +96 -0
  115. package/util/ANSI.ts +29 -0
  116. package/util/extractCode.ts +217 -0
  117. package/util/extractToolCalls.ts +80 -0
  118. 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
+ }