@dexto/tools-filesystem 1.6.25 → 1.6.26

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 (42) hide show
  1. package/dist/directory-approval.cjs +6 -4
  2. package/dist/directory-approval.d.ts.map +1 -1
  3. package/dist/directory-approval.integration.test.cjs +235 -6
  4. package/dist/directory-approval.integration.test.js +236 -6
  5. package/dist/directory-approval.js +6 -4
  6. package/dist/filesystem-service.cjs +6 -2
  7. package/dist/filesystem-service.d.ts.map +1 -1
  8. package/dist/filesystem-service.js +6 -2
  9. package/dist/glob-files-tool.cjs +3 -13
  10. package/dist/glob-files-tool.d.ts.map +1 -1
  11. package/dist/glob-files-tool.js +3 -3
  12. package/dist/glob-files-tool.test.cjs +115 -0
  13. package/dist/glob-files-tool.test.d.ts +2 -0
  14. package/dist/glob-files-tool.test.d.ts.map +1 -0
  15. package/dist/glob-files-tool.test.js +92 -0
  16. package/dist/grep-content-tool.cjs +3 -13
  17. package/dist/grep-content-tool.d.ts.map +1 -1
  18. package/dist/grep-content-tool.js +3 -3
  19. package/dist/grep-content-tool.test.cjs +115 -0
  20. package/dist/grep-content-tool.test.d.ts +2 -0
  21. package/dist/grep-content-tool.test.d.ts.map +1 -0
  22. package/dist/grep-content-tool.test.js +92 -0
  23. package/dist/path-utils.cjs +55 -0
  24. package/dist/path-utils.d.ts +11 -0
  25. package/dist/path-utils.d.ts.map +1 -0
  26. package/dist/path-utils.js +20 -0
  27. package/dist/path-utils.test.cjs +52 -0
  28. package/dist/path-utils.test.d.ts +2 -0
  29. package/dist/path-utils.test.d.ts.map +1 -0
  30. package/dist/path-utils.test.js +29 -0
  31. package/dist/path-validator.cjs +23 -9
  32. package/dist/path-validator.d.ts.map +1 -1
  33. package/dist/path-validator.js +23 -9
  34. package/dist/path-validator.test.cjs +41 -0
  35. package/dist/path-validator.test.js +19 -0
  36. package/dist/tool-factory-config.d.ts +2 -2
  37. package/dist/tool-factory.cjs +29 -50
  38. package/dist/tool-factory.d.ts.map +1 -1
  39. package/dist/tool-factory.js +29 -50
  40. package/dist/write-file-tool.test.cjs +33 -0
  41. package/dist/write-file-tool.test.js +33 -0
  42. package/package.json +3 -3
@@ -34,8 +34,9 @@ __export(directory_approval_exports, {
34
34
  module.exports = __toCommonJS(directory_approval_exports);
35
35
  var path = __toESM(require("node:path"), 1);
36
36
  var import_core = require("@dexto/core");
37
+ var import_path_utils = require("./path-utils.js");
37
38
  function resolveFilePath(workingDirectory, filePath) {
38
- const resolvedPath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(workingDirectory, filePath);
39
+ const resolvedPath = (0, import_path_utils.resolveUserPath)(workingDirectory, filePath);
39
40
  return { path: resolvedPath, parentDir: path.dirname(resolvedPath) };
40
41
  }
41
42
  function createDirectoryAccessApprovalHandlers(options) {
@@ -56,7 +57,7 @@ function createDirectoryAccessApprovalHandlers(options) {
56
57
  `${options.toolName} requires ToolExecutionContext.services.approval`
57
58
  );
58
59
  }
59
- if (approvalManager.isDirectorySessionApproved(paths.path)) {
60
+ if (approvalManager.isDirectorySessionApproved(paths.path, context.sessionId)) {
60
61
  return null;
61
62
  }
62
63
  return {
@@ -83,9 +84,10 @@ function createDirectoryAccessApprovalHandlers(options) {
83
84
  if (!metadata?.parentDir) {
84
85
  return;
85
86
  }
86
- approvalManager.addApprovedDirectory(
87
+ await approvalManager.addApprovedDirectory(
87
88
  metadata.parentDir,
88
- rememberDirectory ? "session" : "once"
89
+ rememberDirectory ? "session" : "once",
90
+ context.sessionId
89
91
  );
90
92
  }
91
93
  }
@@ -1 +1 @@
1
- {"version":3,"file":"directory-approval.d.ts","sourceRoot":"","sources":["../src/directory-approval.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,CAAC,EAAE,UAAU,EAAE,MAAM,KAAK,CAAC;AAEzC,OAAO,KAAK,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAClG,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAEpE,KAAK,0BAA0B,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAE5D,KAAK,sBAAsB,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,wBAAgB,eAAe,CAC3B,gBAAgB,EAAE,MAAM,EACxB,QAAQ,EAAE,MAAM,GACjB,sBAAsB,CAKxB;AAED,wBAAgB,qCAAqC,CAAC,KAAK,CAAC,OAAO,SAAS,UAAU,EAAE,OAAO,EAAE;IAC7F,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,0BAA0B,CAAC;IACtC,WAAW,EAAE,OAAO,CAAC;IACrB,oBAAoB,EAAE,uBAAuB,CAAC;IAC9C,YAAY,EAAE,CACV,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,EACxB,iBAAiB,EAAE,iBAAiB,KACnC,sBAAsB,CAAC;CAC/B,GAAG;IACA,QAAQ,EAAE;QACN,QAAQ,EAAE,CACN,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,EACxB,OAAO,EAAE,oBAAoB,KAC5B,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAAC;QAC5C,SAAS,EAAE,CACP,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EAAE,oBAAoB,EAC7B,eAAe,EAAE,sBAAsB,KACtC,OAAO,CAAC,IAAI,CAAC,CAAC;KACtB,CAAC;CACL,CA4DA"}
1
+ {"version":3,"file":"directory-approval.d.ts","sourceRoot":"","sources":["../src/directory-approval.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,CAAC,EAAE,UAAU,EAAE,MAAM,KAAK,CAAC;AAEzC,OAAO,KAAK,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAClG,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAGpE,KAAK,0BAA0B,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAE5D,KAAK,sBAAsB,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,wBAAgB,eAAe,CAC3B,gBAAgB,EAAE,MAAM,EACxB,QAAQ,EAAE,MAAM,GACjB,sBAAsB,CAGxB;AAED,wBAAgB,qCAAqC,CAAC,KAAK,CAAC,OAAO,SAAS,UAAU,EAAE,OAAO,EAAE;IAC7F,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,0BAA0B,CAAC;IACtC,WAAW,EAAE,OAAO,CAAC;IACrB,oBAAoB,EAAE,uBAAuB,CAAC;IAC9C,YAAY,EAAE,CACV,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,EACxB,iBAAiB,EAAE,iBAAiB,KACnC,sBAAsB,CAAC;CAC/B,GAAG;IACA,QAAQ,EAAE;QACN,QAAQ,EAAE,CACN,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,EACxB,OAAO,EAAE,oBAAoB,KAC5B,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAAC;QAC5C,SAAS,EAAE,CACP,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EAAE,oBAAoB,EAC7B,eAAe,EAAE,sBAAsB,KACtC,OAAO,CAAC,IAAI,CAAC,CAAC;KACtB,CAAC;CACL,CA6DA"}
@@ -30,6 +30,7 @@ var import_filesystem_service = require("./filesystem-service.js");
30
30
  var import_read_file_tool = require("./read-file-tool.js");
31
31
  var import_write_file_tool = require("./write-file-tool.js");
32
32
  var import_edit_file_tool = require("./edit-file-tool.js");
33
+ var import_tool_factory = require("./tool-factory.js");
33
34
  const createMockLogger = () => {
34
35
  const noopAsync = async () => void 0;
35
36
  const logger = {
@@ -48,9 +49,29 @@ const createMockLogger = () => {
48
49
  };
49
50
  return logger;
50
51
  };
51
- function createToolContext(logger, approval) {
52
+ function createInMemorySessionApprovalStore() {
53
+ const states = /* @__PURE__ */ new Map();
54
+ return {
55
+ async load(sessionId) {
56
+ return structuredClone(
57
+ states.get(sessionId ?? "__global__") ?? {
58
+ toolPatterns: {},
59
+ approvedDirectories: []
60
+ }
61
+ );
62
+ },
63
+ async save(sessionId, state) {
64
+ states.set(sessionId ?? "__global__", structuredClone(state));
65
+ },
66
+ async delete(sessionId) {
67
+ states.delete(sessionId ?? "__global__");
68
+ }
69
+ };
70
+ }
71
+ function createToolContext(logger, approval, sessionId) {
52
72
  return {
53
73
  logger,
74
+ ...sessionId !== void 0 ? { sessionId } : {},
54
75
  services: {
55
76
  approval,
56
77
  search: {},
@@ -90,7 +111,8 @@ function createToolContext(logger, approval) {
90
111
  permissions: { mode: "manual" },
91
112
  elicitation: { enabled: true }
92
113
  },
93
- mockLogger
114
+ mockLogger,
115
+ createInMemorySessionApprovalStore()
94
116
  );
95
117
  toolContext = createToolContext(mockLogger, approvalManager);
96
118
  import_vitest.vi.clearAllMocks();
@@ -135,7 +157,7 @@ function createToolContext(logger, approval) {
135
157
  });
136
158
  });
137
159
  (0, import_vitest.it)("should return null when external path is session-approved", async () => {
138
- approvalManager.addApprovedDirectory("/external/project", "session");
160
+ await approvalManager.addApprovedDirectory("/external/project", "session");
139
161
  const tool = (0, import_read_file_tool.createReadFileTool)(getFileSystemService);
140
162
  const overrideFn = tool.approval?.override;
141
163
  (0, import_vitest.expect)(overrideFn).toBeDefined();
@@ -147,7 +169,7 @@ function createToolContext(logger, approval) {
147
169
  (0, import_vitest.expect)(metadata).toBeNull();
148
170
  });
149
171
  (0, import_vitest.it)("should still return metadata when external path is once-approved (prompt again)", async () => {
150
- approvalManager.addApprovedDirectory("/external/project", "once");
172
+ await approvalManager.addApprovedDirectory("/external/project", "once");
151
173
  const tool = (0, import_read_file_tool.createReadFileTool)(getFileSystemService);
152
174
  const overrideFn = tool.approval?.override;
153
175
  (0, import_vitest.expect)(overrideFn).toBeDefined();
@@ -158,6 +180,42 @@ function createToolContext(logger, approval) {
158
180
  );
159
181
  (0, import_vitest.expect)(metadata).not.toBeNull();
160
182
  });
183
+ (0, import_vitest.it)("should remember directory approvals only for the granting session", async () => {
184
+ const tool = (0, import_read_file_tool.createReadFileTool)(getFileSystemService);
185
+ const overrideFn = tool.approval?.override;
186
+ const onGrantedFn = tool.approval?.onGranted;
187
+ (0, import_vitest.expect)(overrideFn).toBeDefined();
188
+ (0, import_vitest.expect)(onGrantedFn).toBeDefined();
189
+ const externalPath = "/external/project/file.ts";
190
+ const sessionAContext = createToolContext(mockLogger, approvalManager, "session-a");
191
+ const sessionBContext = createToolContext(mockLogger, approvalManager, "session-b");
192
+ const approvalRequest = await overrideFn(
193
+ tool.inputSchema.parse({ file_path: externalPath }),
194
+ sessionAContext
195
+ );
196
+ (0, import_vitest.expect)(approvalRequest).not.toBeNull();
197
+ await onGrantedFn(
198
+ {
199
+ approvalId: "approval-1",
200
+ status: import_core.ApprovalStatus.APPROVED,
201
+ data: { rememberDirectory: true }
202
+ },
203
+ sessionAContext,
204
+ approvalRequest
205
+ );
206
+ (0, import_vitest.expect)(
207
+ await overrideFn(
208
+ tool.inputSchema.parse({ file_path: externalPath }),
209
+ sessionAContext
210
+ )
211
+ ).toBeNull();
212
+ (0, import_vitest.expect)(
213
+ await overrideFn(
214
+ tool.inputSchema.parse({ file_path: externalPath }),
215
+ sessionBContext
216
+ )
217
+ ).not.toBeNull();
218
+ });
161
219
  });
162
220
  (0, import_vitest.describe)("Different tool operations", () => {
163
221
  (0, import_vitest.it)("should label write operations correctly", async () => {
@@ -210,7 +268,7 @@ function createToolContext(logger, approval) {
210
268
  const tool = (0, import_read_file_tool.createReadFileTool)(getFileSystemService);
211
269
  const overrideFn = tool.approval?.override;
212
270
  (0, import_vitest.expect)(overrideFn).toBeDefined();
213
- approvalManager.addApprovedDirectory("/external/project", "session");
271
+ await approvalManager.addApprovedDirectory("/external/project", "session");
214
272
  const metadata1 = await overrideFn(
215
273
  tool.inputSchema.parse({ file_path: "/external/project/file.ts" }),
216
274
  toolContext
@@ -226,7 +284,7 @@ function createToolContext(logger, approval) {
226
284
  const tool = (0, import_read_file_tool.createReadFileTool)(getFileSystemService);
227
285
  const overrideFn = tool.approval?.override;
228
286
  (0, import_vitest.expect)(overrideFn).toBeDefined();
229
- approvalManager.addApprovedDirectory("/external/sub", "session");
287
+ await approvalManager.addApprovedDirectory("/external/sub", "session");
230
288
  const metadata1 = await overrideFn(
231
289
  tool.inputSchema.parse({ file_path: "/external/sub/file.ts" }),
232
290
  toolContext
@@ -239,6 +297,177 @@ function createToolContext(logger, approval) {
239
297
  (0, import_vitest.expect)(metadata2).not.toBeNull();
240
298
  });
241
299
  });
300
+ (0, import_vitest.describe)("Execution approval scoping", () => {
301
+ (0, import_vitest.it)("should allow execution only for the session that holds the approved directory", async () => {
302
+ const tools = import_tool_factory.fileSystemToolsFactory.create({
303
+ type: "filesystem-tools",
304
+ allowedPaths: [tempDir],
305
+ blockedPaths: [],
306
+ blockedExtensions: [],
307
+ maxFileSize: 10 * 1024 * 1024,
308
+ workingDirectory: tempDir,
309
+ enableBackups: false,
310
+ backupRetentionDays: 7
311
+ });
312
+ const writeTool = tools.find((tool) => tool.id === "write_file");
313
+ (0, import_vitest.expect)(writeTool).toBeDefined();
314
+ const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), "dexto-fs-external-"));
315
+ try {
316
+ const externalFile = path.join(externalDir, "approved.txt");
317
+ await approvalManager.addApprovedDirectory(externalDir, "once", "session-a");
318
+ await (0, import_vitest.expect)(
319
+ writeTool.execute(
320
+ writeTool.inputSchema.parse({
321
+ file_path: externalFile,
322
+ content: "session-scoped write"
323
+ }),
324
+ createToolContext(mockLogger, approvalManager, "session-a")
325
+ )
326
+ ).resolves.toEqual(
327
+ import_vitest.expect.objectContaining({
328
+ success: true,
329
+ path: path.resolve(externalFile)
330
+ })
331
+ );
332
+ await (0, import_vitest.expect)(fs.readFile(externalFile, "utf8")).resolves.toBe(
333
+ "session-scoped write"
334
+ );
335
+ await (0, import_vitest.expect)(
336
+ writeTool.execute(
337
+ writeTool.inputSchema.parse({
338
+ file_path: path.join(externalDir, "blocked.txt"),
339
+ content: "should fail"
340
+ }),
341
+ createToolContext(mockLogger, approvalManager, "session-b")
342
+ )
343
+ ).rejects.toBeInstanceOf(import_core.DextoRuntimeError);
344
+ } finally {
345
+ await fs.rm(externalDir, { recursive: true, force: true });
346
+ }
347
+ });
348
+ });
349
+ (0, import_vitest.describe)("Factory service scoping", () => {
350
+ (0, import_vitest.it)("should keep working directories isolated across concurrent executions", async () => {
351
+ const workspaceA = path.join(tempDir, "workspace-a");
352
+ const workspaceB = path.join(tempDir, "workspace-b");
353
+ await fs.mkdir(workspaceA, { recursive: true });
354
+ await fs.mkdir(workspaceB, { recursive: true });
355
+ await fs.writeFile(path.join(workspaceA, "same.txt"), "from workspace A");
356
+ await fs.writeFile(path.join(workspaceB, "same.txt"), "from workspace B");
357
+ const tools = import_tool_factory.fileSystemToolsFactory.create({
358
+ type: "filesystem-tools",
359
+ allowedPaths: [workspaceA, workspaceB],
360
+ blockedPaths: [],
361
+ blockedExtensions: [],
362
+ maxFileSize: 10 * 1024 * 1024,
363
+ workingDirectory: tempDir,
364
+ enableBackups: false,
365
+ backupRetentionDays: 7,
366
+ enabledTools: ["read_file"]
367
+ });
368
+ const readTool = tools.find((tool) => tool.id === "read_file");
369
+ (0, import_vitest.expect)(readTool).toBeDefined();
370
+ const baseContext = createToolContext(mockLogger, approvalManager);
371
+ const contextA = {
372
+ ...baseContext,
373
+ sessionId: "session-a",
374
+ workspace: {
375
+ id: "workspace-a",
376
+ path: workspaceA,
377
+ createdAt: Date.now(),
378
+ lastActiveAt: Date.now()
379
+ }
380
+ };
381
+ const contextB = {
382
+ ...baseContext,
383
+ sessionId: "session-b",
384
+ workspace: {
385
+ id: "workspace-b",
386
+ path: workspaceB,
387
+ createdAt: Date.now(),
388
+ lastActiveAt: Date.now()
389
+ }
390
+ };
391
+ const [resultA, resultB] = await Promise.all([
392
+ readTool.execute(
393
+ readTool.inputSchema.parse({ file_path: "same.txt" }),
394
+ contextA
395
+ ),
396
+ readTool.execute(
397
+ readTool.inputSchema.parse({ file_path: "same.txt" }),
398
+ contextB
399
+ )
400
+ ]);
401
+ (0, import_vitest.expect)(resultA).toEqual(
402
+ import_vitest.expect.objectContaining({
403
+ content: "from workspace A"
404
+ })
405
+ );
406
+ (0, import_vitest.expect)(resultB).toEqual(
407
+ import_vitest.expect.objectContaining({
408
+ content: "from workspace B"
409
+ })
410
+ );
411
+ });
412
+ (0, import_vitest.it)("should not mutate an injected filesystem service with session-specific state", async () => {
413
+ const workspace = path.join(tempDir, "workspace-injected");
414
+ await fs.mkdir(workspace, { recursive: true });
415
+ await fs.writeFile(path.join(workspace, "same.txt"), "from injected config");
416
+ const tools = import_tool_factory.fileSystemToolsFactory.create({
417
+ type: "filesystem-tools",
418
+ allowedPaths: [workspace],
419
+ blockedPaths: [],
420
+ blockedExtensions: [],
421
+ maxFileSize: 10 * 1024 * 1024,
422
+ workingDirectory: tempDir,
423
+ enableBackups: false,
424
+ backupRetentionDays: 7,
425
+ enabledTools: ["read_file"]
426
+ });
427
+ const readTool = tools.find((tool) => tool.id === "read_file");
428
+ (0, import_vitest.expect)(readTool).toBeDefined();
429
+ const injectedService = {
430
+ getConfig: import_vitest.vi.fn().mockReturnValue({
431
+ allowedPaths: [workspace],
432
+ blockedPaths: [],
433
+ blockedExtensions: [],
434
+ maxFileSize: 10 * 1024 * 1024,
435
+ workingDirectory: workspace,
436
+ enableBackups: false,
437
+ backupRetentionDays: 7
438
+ }),
439
+ readFile: import_vitest.vi.fn(),
440
+ writeFile: import_vitest.vi.fn(),
441
+ setWorkingDirectory: import_vitest.vi.fn(),
442
+ setDirectoryApprovalChecker: import_vitest.vi.fn()
443
+ };
444
+ const baseContext = createToolContext(mockLogger, approvalManager, "session-a");
445
+ const context = {
446
+ ...baseContext,
447
+ workspace: {
448
+ id: "workspace-injected",
449
+ path: workspace,
450
+ createdAt: Date.now(),
451
+ lastActiveAt: Date.now()
452
+ },
453
+ services: {
454
+ ...baseContext.services,
455
+ filesystemService: injectedService
456
+ }
457
+ };
458
+ const result = await readTool.execute(
459
+ readTool.inputSchema.parse({ file_path: "same.txt" }),
460
+ context
461
+ );
462
+ (0, import_vitest.expect)(result).toEqual(
463
+ import_vitest.expect.objectContaining({
464
+ content: "from injected config"
465
+ })
466
+ );
467
+ (0, import_vitest.expect)(injectedService.setWorkingDirectory).not.toHaveBeenCalled();
468
+ (0, import_vitest.expect)(injectedService.setDirectoryApprovalChecker).not.toHaveBeenCalled();
469
+ });
470
+ });
242
471
  (0, import_vitest.describe)("Without ApprovalManager in context", () => {
243
472
  (0, import_vitest.it)("should throw for external paths", async () => {
244
473
  const tool = (0, import_read_file_tool.createReadFileTool)(getFileSystemService);
@@ -4,12 +4,14 @@ import * as fs from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import {
6
6
  ApprovalManager,
7
+ ApprovalStatus,
7
8
  DextoRuntimeError
8
9
  } from "@dexto/core";
9
10
  import { FileSystemService } from "./filesystem-service.js";
10
11
  import { createReadFileTool } from "./read-file-tool.js";
11
12
  import { createWriteFileTool } from "./write-file-tool.js";
12
13
  import { createEditFileTool } from "./edit-file-tool.js";
14
+ import { fileSystemToolsFactory } from "./tool-factory.js";
13
15
  const createMockLogger = () => {
14
16
  const noopAsync = async () => void 0;
15
17
  const logger = {
@@ -28,9 +30,29 @@ const createMockLogger = () => {
28
30
  };
29
31
  return logger;
30
32
  };
31
- function createToolContext(logger, approval) {
33
+ function createInMemorySessionApprovalStore() {
34
+ const states = /* @__PURE__ */ new Map();
35
+ return {
36
+ async load(sessionId) {
37
+ return structuredClone(
38
+ states.get(sessionId ?? "__global__") ?? {
39
+ toolPatterns: {},
40
+ approvedDirectories: []
41
+ }
42
+ );
43
+ },
44
+ async save(sessionId, state) {
45
+ states.set(sessionId ?? "__global__", structuredClone(state));
46
+ },
47
+ async delete(sessionId) {
48
+ states.delete(sessionId ?? "__global__");
49
+ }
50
+ };
51
+ }
52
+ function createToolContext(logger, approval, sessionId) {
32
53
  return {
33
54
  logger,
55
+ ...sessionId !== void 0 ? { sessionId } : {},
34
56
  services: {
35
57
  approval,
36
58
  search: {},
@@ -70,7 +92,8 @@ describe("Directory Approval Integration Tests", () => {
70
92
  permissions: { mode: "manual" },
71
93
  elicitation: { enabled: true }
72
94
  },
73
- mockLogger
95
+ mockLogger,
96
+ createInMemorySessionApprovalStore()
74
97
  );
75
98
  toolContext = createToolContext(mockLogger, approvalManager);
76
99
  vi.clearAllMocks();
@@ -115,7 +138,7 @@ describe("Directory Approval Integration Tests", () => {
115
138
  });
116
139
  });
117
140
  it("should return null when external path is session-approved", async () => {
118
- approvalManager.addApprovedDirectory("/external/project", "session");
141
+ await approvalManager.addApprovedDirectory("/external/project", "session");
119
142
  const tool = createReadFileTool(getFileSystemService);
120
143
  const overrideFn = tool.approval?.override;
121
144
  expect(overrideFn).toBeDefined();
@@ -127,7 +150,7 @@ describe("Directory Approval Integration Tests", () => {
127
150
  expect(metadata).toBeNull();
128
151
  });
129
152
  it("should still return metadata when external path is once-approved (prompt again)", async () => {
130
- approvalManager.addApprovedDirectory("/external/project", "once");
153
+ await approvalManager.addApprovedDirectory("/external/project", "once");
131
154
  const tool = createReadFileTool(getFileSystemService);
132
155
  const overrideFn = tool.approval?.override;
133
156
  expect(overrideFn).toBeDefined();
@@ -138,6 +161,42 @@ describe("Directory Approval Integration Tests", () => {
138
161
  );
139
162
  expect(metadata).not.toBeNull();
140
163
  });
164
+ it("should remember directory approvals only for the granting session", async () => {
165
+ const tool = createReadFileTool(getFileSystemService);
166
+ const overrideFn = tool.approval?.override;
167
+ const onGrantedFn = tool.approval?.onGranted;
168
+ expect(overrideFn).toBeDefined();
169
+ expect(onGrantedFn).toBeDefined();
170
+ const externalPath = "/external/project/file.ts";
171
+ const sessionAContext = createToolContext(mockLogger, approvalManager, "session-a");
172
+ const sessionBContext = createToolContext(mockLogger, approvalManager, "session-b");
173
+ const approvalRequest = await overrideFn(
174
+ tool.inputSchema.parse({ file_path: externalPath }),
175
+ sessionAContext
176
+ );
177
+ expect(approvalRequest).not.toBeNull();
178
+ await onGrantedFn(
179
+ {
180
+ approvalId: "approval-1",
181
+ status: ApprovalStatus.APPROVED,
182
+ data: { rememberDirectory: true }
183
+ },
184
+ sessionAContext,
185
+ approvalRequest
186
+ );
187
+ expect(
188
+ await overrideFn(
189
+ tool.inputSchema.parse({ file_path: externalPath }),
190
+ sessionAContext
191
+ )
192
+ ).toBeNull();
193
+ expect(
194
+ await overrideFn(
195
+ tool.inputSchema.parse({ file_path: externalPath }),
196
+ sessionBContext
197
+ )
198
+ ).not.toBeNull();
199
+ });
141
200
  });
142
201
  describe("Different tool operations", () => {
143
202
  it("should label write operations correctly", async () => {
@@ -190,7 +249,7 @@ describe("Directory Approval Integration Tests", () => {
190
249
  const tool = createReadFileTool(getFileSystemService);
191
250
  const overrideFn = tool.approval?.override;
192
251
  expect(overrideFn).toBeDefined();
193
- approvalManager.addApprovedDirectory("/external/project", "session");
252
+ await approvalManager.addApprovedDirectory("/external/project", "session");
194
253
  const metadata1 = await overrideFn(
195
254
  tool.inputSchema.parse({ file_path: "/external/project/file.ts" }),
196
255
  toolContext
@@ -206,7 +265,7 @@ describe("Directory Approval Integration Tests", () => {
206
265
  const tool = createReadFileTool(getFileSystemService);
207
266
  const overrideFn = tool.approval?.override;
208
267
  expect(overrideFn).toBeDefined();
209
- approvalManager.addApprovedDirectory("/external/sub", "session");
268
+ await approvalManager.addApprovedDirectory("/external/sub", "session");
210
269
  const metadata1 = await overrideFn(
211
270
  tool.inputSchema.parse({ file_path: "/external/sub/file.ts" }),
212
271
  toolContext
@@ -219,6 +278,177 @@ describe("Directory Approval Integration Tests", () => {
219
278
  expect(metadata2).not.toBeNull();
220
279
  });
221
280
  });
281
+ describe("Execution approval scoping", () => {
282
+ it("should allow execution only for the session that holds the approved directory", async () => {
283
+ const tools = fileSystemToolsFactory.create({
284
+ type: "filesystem-tools",
285
+ allowedPaths: [tempDir],
286
+ blockedPaths: [],
287
+ blockedExtensions: [],
288
+ maxFileSize: 10 * 1024 * 1024,
289
+ workingDirectory: tempDir,
290
+ enableBackups: false,
291
+ backupRetentionDays: 7
292
+ });
293
+ const writeTool = tools.find((tool) => tool.id === "write_file");
294
+ expect(writeTool).toBeDefined();
295
+ const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), "dexto-fs-external-"));
296
+ try {
297
+ const externalFile = path.join(externalDir, "approved.txt");
298
+ await approvalManager.addApprovedDirectory(externalDir, "once", "session-a");
299
+ await expect(
300
+ writeTool.execute(
301
+ writeTool.inputSchema.parse({
302
+ file_path: externalFile,
303
+ content: "session-scoped write"
304
+ }),
305
+ createToolContext(mockLogger, approvalManager, "session-a")
306
+ )
307
+ ).resolves.toEqual(
308
+ expect.objectContaining({
309
+ success: true,
310
+ path: path.resolve(externalFile)
311
+ })
312
+ );
313
+ await expect(fs.readFile(externalFile, "utf8")).resolves.toBe(
314
+ "session-scoped write"
315
+ );
316
+ await expect(
317
+ writeTool.execute(
318
+ writeTool.inputSchema.parse({
319
+ file_path: path.join(externalDir, "blocked.txt"),
320
+ content: "should fail"
321
+ }),
322
+ createToolContext(mockLogger, approvalManager, "session-b")
323
+ )
324
+ ).rejects.toBeInstanceOf(DextoRuntimeError);
325
+ } finally {
326
+ await fs.rm(externalDir, { recursive: true, force: true });
327
+ }
328
+ });
329
+ });
330
+ describe("Factory service scoping", () => {
331
+ it("should keep working directories isolated across concurrent executions", async () => {
332
+ const workspaceA = path.join(tempDir, "workspace-a");
333
+ const workspaceB = path.join(tempDir, "workspace-b");
334
+ await fs.mkdir(workspaceA, { recursive: true });
335
+ await fs.mkdir(workspaceB, { recursive: true });
336
+ await fs.writeFile(path.join(workspaceA, "same.txt"), "from workspace A");
337
+ await fs.writeFile(path.join(workspaceB, "same.txt"), "from workspace B");
338
+ const tools = fileSystemToolsFactory.create({
339
+ type: "filesystem-tools",
340
+ allowedPaths: [workspaceA, workspaceB],
341
+ blockedPaths: [],
342
+ blockedExtensions: [],
343
+ maxFileSize: 10 * 1024 * 1024,
344
+ workingDirectory: tempDir,
345
+ enableBackups: false,
346
+ backupRetentionDays: 7,
347
+ enabledTools: ["read_file"]
348
+ });
349
+ const readTool = tools.find((tool) => tool.id === "read_file");
350
+ expect(readTool).toBeDefined();
351
+ const baseContext = createToolContext(mockLogger, approvalManager);
352
+ const contextA = {
353
+ ...baseContext,
354
+ sessionId: "session-a",
355
+ workspace: {
356
+ id: "workspace-a",
357
+ path: workspaceA,
358
+ createdAt: Date.now(),
359
+ lastActiveAt: Date.now()
360
+ }
361
+ };
362
+ const contextB = {
363
+ ...baseContext,
364
+ sessionId: "session-b",
365
+ workspace: {
366
+ id: "workspace-b",
367
+ path: workspaceB,
368
+ createdAt: Date.now(),
369
+ lastActiveAt: Date.now()
370
+ }
371
+ };
372
+ const [resultA, resultB] = await Promise.all([
373
+ readTool.execute(
374
+ readTool.inputSchema.parse({ file_path: "same.txt" }),
375
+ contextA
376
+ ),
377
+ readTool.execute(
378
+ readTool.inputSchema.parse({ file_path: "same.txt" }),
379
+ contextB
380
+ )
381
+ ]);
382
+ expect(resultA).toEqual(
383
+ expect.objectContaining({
384
+ content: "from workspace A"
385
+ })
386
+ );
387
+ expect(resultB).toEqual(
388
+ expect.objectContaining({
389
+ content: "from workspace B"
390
+ })
391
+ );
392
+ });
393
+ it("should not mutate an injected filesystem service with session-specific state", async () => {
394
+ const workspace = path.join(tempDir, "workspace-injected");
395
+ await fs.mkdir(workspace, { recursive: true });
396
+ await fs.writeFile(path.join(workspace, "same.txt"), "from injected config");
397
+ const tools = fileSystemToolsFactory.create({
398
+ type: "filesystem-tools",
399
+ allowedPaths: [workspace],
400
+ blockedPaths: [],
401
+ blockedExtensions: [],
402
+ maxFileSize: 10 * 1024 * 1024,
403
+ workingDirectory: tempDir,
404
+ enableBackups: false,
405
+ backupRetentionDays: 7,
406
+ enabledTools: ["read_file"]
407
+ });
408
+ const readTool = tools.find((tool) => tool.id === "read_file");
409
+ expect(readTool).toBeDefined();
410
+ const injectedService = {
411
+ getConfig: vi.fn().mockReturnValue({
412
+ allowedPaths: [workspace],
413
+ blockedPaths: [],
414
+ blockedExtensions: [],
415
+ maxFileSize: 10 * 1024 * 1024,
416
+ workingDirectory: workspace,
417
+ enableBackups: false,
418
+ backupRetentionDays: 7
419
+ }),
420
+ readFile: vi.fn(),
421
+ writeFile: vi.fn(),
422
+ setWorkingDirectory: vi.fn(),
423
+ setDirectoryApprovalChecker: vi.fn()
424
+ };
425
+ const baseContext = createToolContext(mockLogger, approvalManager, "session-a");
426
+ const context = {
427
+ ...baseContext,
428
+ workspace: {
429
+ id: "workspace-injected",
430
+ path: workspace,
431
+ createdAt: Date.now(),
432
+ lastActiveAt: Date.now()
433
+ },
434
+ services: {
435
+ ...baseContext.services,
436
+ filesystemService: injectedService
437
+ }
438
+ };
439
+ const result = await readTool.execute(
440
+ readTool.inputSchema.parse({ file_path: "same.txt" }),
441
+ context
442
+ );
443
+ expect(result).toEqual(
444
+ expect.objectContaining({
445
+ content: "from injected config"
446
+ })
447
+ );
448
+ expect(injectedService.setWorkingDirectory).not.toHaveBeenCalled();
449
+ expect(injectedService.setDirectoryApprovalChecker).not.toHaveBeenCalled();
450
+ });
451
+ });
222
452
  describe("Without ApprovalManager in context", () => {
223
453
  it("should throw for external paths", async () => {
224
454
  const tool = createReadFileTool(getFileSystemService);
@@ -1,7 +1,8 @@
1
1
  import * as path from "node:path";
2
2
  import { ApprovalStatus, ApprovalType, ToolError } from "@dexto/core";
3
+ import { resolveUserPath } from "./path-utils.js";
3
4
  function resolveFilePath(workingDirectory, filePath) {
4
- const resolvedPath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(workingDirectory, filePath);
5
+ const resolvedPath = resolveUserPath(workingDirectory, filePath);
5
6
  return { path: resolvedPath, parentDir: path.dirname(resolvedPath) };
6
7
  }
7
8
  function createDirectoryAccessApprovalHandlers(options) {
@@ -22,7 +23,7 @@ function createDirectoryAccessApprovalHandlers(options) {
22
23
  `${options.toolName} requires ToolExecutionContext.services.approval`
23
24
  );
24
25
  }
25
- if (approvalManager.isDirectorySessionApproved(paths.path)) {
26
+ if (approvalManager.isDirectorySessionApproved(paths.path, context.sessionId)) {
26
27
  return null;
27
28
  }
28
29
  return {
@@ -49,9 +50,10 @@ function createDirectoryAccessApprovalHandlers(options) {
49
50
  if (!metadata?.parentDir) {
50
51
  return;
51
52
  }
52
- approvalManager.addApprovedDirectory(
53
+ await approvalManager.addApprovedDirectory(
53
54
  metadata.parentDir,
54
- rememberDirectory ? "session" : "once"
55
+ rememberDirectory ? "session" : "once",
56
+ context.sessionId
55
57
  );
56
58
  }
57
59
  }