@dexto/tools-filesystem 1.6.25 → 1.6.27
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/dist/directory-approval.cjs +6 -4
- package/dist/directory-approval.d.ts.map +1 -1
- package/dist/directory-approval.integration.test.cjs +235 -6
- package/dist/directory-approval.integration.test.js +236 -6
- package/dist/directory-approval.js +6 -4
- package/dist/filesystem-service.cjs +6 -2
- package/dist/filesystem-service.d.ts.map +1 -1
- package/dist/filesystem-service.js +6 -2
- package/dist/glob-files-tool.cjs +3 -13
- package/dist/glob-files-tool.d.ts.map +1 -1
- package/dist/glob-files-tool.js +3 -3
- package/dist/glob-files-tool.test.cjs +115 -0
- package/dist/glob-files-tool.test.d.ts +2 -0
- package/dist/glob-files-tool.test.d.ts.map +1 -0
- package/dist/glob-files-tool.test.js +92 -0
- package/dist/grep-content-tool.cjs +3 -13
- package/dist/grep-content-tool.d.ts.map +1 -1
- package/dist/grep-content-tool.js +3 -3
- package/dist/grep-content-tool.test.cjs +115 -0
- package/dist/grep-content-tool.test.d.ts +2 -0
- package/dist/grep-content-tool.test.d.ts.map +1 -0
- package/dist/grep-content-tool.test.js +92 -0
- package/dist/path-utils.cjs +55 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.d.ts.map +1 -0
- package/dist/path-utils.js +20 -0
- package/dist/path-utils.test.cjs +52 -0
- package/dist/path-utils.test.d.ts +2 -0
- package/dist/path-utils.test.d.ts.map +1 -0
- package/dist/path-utils.test.js +29 -0
- package/dist/path-validator.cjs +23 -9
- package/dist/path-validator.d.ts.map +1 -1
- package/dist/path-validator.js +23 -9
- package/dist/path-validator.test.cjs +41 -0
- package/dist/path-validator.test.js +19 -0
- package/dist/tool-factory-config.d.ts +2 -2
- package/dist/tool-factory.cjs +29 -50
- package/dist/tool-factory.d.ts.map +1 -1
- package/dist/tool-factory.js +29 -50
- package/dist/write-file-tool.test.cjs +33 -0
- package/dist/write-file-tool.test.js +33 -0
- 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 =
|
|
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;
|
|
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
|
|
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
|
|
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 =
|
|
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
|
}
|