@juspay/neurolink 12.27.2 → 12.28.1
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/CHANGELOG.md +2 -2
- package/dist/agent/directTools.d.ts +118 -94
- package/dist/agent/directTools.js +578 -446
- package/dist/agent/index.d.ts +1 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/prompts/terminalAgentPrompt.d.ts +13 -0
- package/dist/agent/prompts/terminalAgentPrompt.js +59 -0
- package/dist/browser/neurolink.min.js +384 -378
- package/dist/cli/factories/commandFactory.d.ts +25 -0
- package/dist/cli/factories/commandFactory.js +78 -8
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/core/baseProvider.d.ts +17 -1
- package/dist/core/baseProvider.js +48 -1
- package/dist/core/modules/ToolsManager.d.ts +7 -1
- package/dist/core/modules/ToolsManager.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/toolRegistry.d.ts +12 -1
- package/dist/mcp/toolRegistry.js +26 -2
- package/dist/neurolink.d.ts +13 -0
- package/dist/neurolink.js +97 -10
- package/dist/session/globalSessionState.d.ts +8 -0
- package/dist/session/globalSessionState.js +17 -0
- package/dist/types/agentMode.d.ts +9 -0
- package/dist/types/agentMode.js +1 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/config.d.ts +9 -0
- package/dist/types/fileToolRoots.d.ts +17 -0
- package/dist/types/fileToolRoots.js +7 -0
- package/dist/types/generate.d.ts +17 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/providers.d.ts +6 -0
- package/dist/types/stream.d.ts +7 -0
- package/dist/types/tools.d.ts +7 -0
- package/dist/utils/fileToolRoots.d.ts +34 -0
- package/dist/utils/fileToolRoots.js +111 -0
- package/dist/utils/messageBuilder.js +32 -1
- package/dist/utils/pathSandbox.d.ts +9 -6
- package/dist/utils/pathSandbox.js +35 -9
- package/docs-site/static/search-index.json +6 -5
- package/package.json +6 -2
|
@@ -7,6 +7,7 @@ import { CSVProcessor } from "../utils/csvProcessor.js";
|
|
|
7
7
|
import { shouldEnableBashTool } from "../utils/toolUtils.js";
|
|
8
8
|
import { tool } from "../utils/tool.js";
|
|
9
9
|
import { withTimeout } from "../utils/async/withTimeout.js";
|
|
10
|
+
import { resolvePathWithinRoot, resolveWithinRoot, } from "../utils/pathSandbox.js";
|
|
10
11
|
import { TIMEOUTS } from "../constants/timeouts.js";
|
|
11
12
|
const MAX_OUTPUT_BYTES = 102400; // 100KB
|
|
12
13
|
function truncateOutput(output) {
|
|
@@ -15,27 +16,105 @@ function truncateOutput(output) {
|
|
|
15
16
|
}
|
|
16
17
|
return output;
|
|
17
18
|
}
|
|
19
|
+
/** The historical default: the process working directory, read at call time. */
|
|
20
|
+
const WORKING_DIRECTORY_POLICY = Object.freeze({
|
|
21
|
+
roots: null,
|
|
22
|
+
});
|
|
23
|
+
function rootsOf(policy) {
|
|
24
|
+
return policy.roots ?? [process.cwd()];
|
|
25
|
+
}
|
|
26
|
+
function withRoots(description, policy) {
|
|
27
|
+
if (policy.roots === null) {
|
|
28
|
+
return description;
|
|
29
|
+
}
|
|
30
|
+
const allowed = policy.roots.length
|
|
31
|
+
? policy.roots.join(", ")
|
|
32
|
+
: "none (file access is disabled for this request)";
|
|
33
|
+
return `${description}. Allowed roots: ${allowed}`;
|
|
34
|
+
}
|
|
18
35
|
/**
|
|
19
|
-
* Contain a built-in file tool to
|
|
36
|
+
* Contain a built-in file tool to its roots.
|
|
20
37
|
*
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* !path.isAbsolute(filePath)`) was always false for absolute paths, so it never
|
|
27
|
-
* fired. The `cwd + path.sep` suffix prevents a sibling-prefix bypass
|
|
28
|
-
* (`/home/app` vs `/home/app-evil`).
|
|
38
|
+
* Symlinks are resolved on both sides and the prefix test is separator-bounded
|
|
39
|
+
* (`resolvePathWithinRoot`), so neither `<root>/link → /etc` nor
|
|
40
|
+
* `/home/app-evil` against `/home/app` gets through. A path that does not
|
|
41
|
+
* exist yet is checked on its nearest existing ancestor, which is what a
|
|
42
|
+
* write needs. Relative paths resolve against the first root.
|
|
29
43
|
*/
|
|
30
|
-
function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
if (resolvedPath !== cwd && !resolvedPath.startsWith(cwd + path.sep)) {
|
|
44
|
+
function resolveWithinFileToolRoots(target, policy) {
|
|
45
|
+
const roots = rootsOf(policy);
|
|
46
|
+
if (roots.length === 0) {
|
|
34
47
|
return {
|
|
35
|
-
error:
|
|
48
|
+
error: "Access denied: file tools are disabled for this request (no tool roots are configured)",
|
|
36
49
|
};
|
|
37
50
|
}
|
|
38
|
-
|
|
51
|
+
const requested = path.isAbsolute(target)
|
|
52
|
+
? target
|
|
53
|
+
: path.resolve(roots[0], target);
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
const contained = resolvePathWithinRoot(requested, root);
|
|
56
|
+
if (contained.path !== undefined) {
|
|
57
|
+
return contained;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
error: policy.roots === null
|
|
62
|
+
? `Access denied: "${target}" resolves outside the working directory`
|
|
63
|
+
: `Access denied: "${target}" resolves outside the permitted roots (${roots.join(", ")})`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Unavailable on Windows, where it is 0 and has no effect. */
|
|
67
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
68
|
+
/**
|
|
69
|
+
* Write without following a symlink at the final path component. The root
|
|
70
|
+
* check ran on the resolved path, so a link that appears there afterwards, or
|
|
71
|
+
* a dangling one, is refused by the kernel instead of redirecting the write:
|
|
72
|
+
* `create` uses O_EXCL (which also fails on an existing link), the other
|
|
73
|
+
* modes O_NOFOLLOW.
|
|
74
|
+
*/
|
|
75
|
+
function writeWithoutFollowingLinks(resolvedPath, content, mode) {
|
|
76
|
+
const { O_WRONLY, O_CREAT, O_EXCL, O_TRUNC, O_APPEND } = fs.constants;
|
|
77
|
+
const flags = mode === "create"
|
|
78
|
+
? O_WRONLY | O_CREAT | O_EXCL
|
|
79
|
+
: O_WRONLY |
|
|
80
|
+
O_CREAT |
|
|
81
|
+
O_NOFOLLOW |
|
|
82
|
+
(mode === "append" ? O_APPEND : O_TRUNC);
|
|
83
|
+
const fd = fs.openSync(resolvedPath, flags, 0o666);
|
|
84
|
+
try {
|
|
85
|
+
fs.writeFileSync(fd, content, "utf-8");
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Read without following a symlink at the final path component. */
|
|
92
|
+
function readWithoutFollowingLinks(resolvedPath) {
|
|
93
|
+
const fd = fs.openSync(resolvedPath, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
94
|
+
try {
|
|
95
|
+
return fs.readFileSync(fd);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
fs.closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const ZIP_SIGNATURE = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
|
102
|
+
const OLE2_SIGNATURE = Buffer.from([
|
|
103
|
+
0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
|
|
104
|
+
]);
|
|
105
|
+
/**
|
|
106
|
+
* Office documents are archives or compound files, so decoding them as UTF-8
|
|
107
|
+
* hands the model mojibake it then tries to reason about. Detected by content
|
|
108
|
+
* because extensions are often missing or wrong.
|
|
109
|
+
*/
|
|
110
|
+
function detectBinaryDocumentFormat(bytes) {
|
|
111
|
+
if (bytes.subarray(0, ZIP_SIGNATURE.length).equals(ZIP_SIGNATURE)) {
|
|
112
|
+
return "ZIP-packaged (such as .docx, .xlsx, .pptx, .odt)";
|
|
113
|
+
}
|
|
114
|
+
if (bytes.subarray(0, OLE2_SIGNATURE.length).equals(OLE2_SIGNATURE)) {
|
|
115
|
+
return "OLE2 compound (such as legacy .doc, .xls, .ppt)";
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
39
118
|
}
|
|
40
119
|
/**
|
|
41
120
|
* Vertex location for the websearchGrounding tool:
|
|
@@ -110,59 +189,36 @@ export function buildWebsearchResults(groundingMetadata, searchContent, limitedR
|
|
|
110
189
|
return searchResults;
|
|
111
190
|
}
|
|
112
191
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
192
|
+
* The file tools, bound to one root policy. Built per request when roots are
|
|
193
|
+
* configured, so concurrent requests never share a boundary.
|
|
115
194
|
*/
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
description: "
|
|
119
|
-
inputSchema: z.object({
|
|
120
|
-
timezone: z
|
|
121
|
-
.string()
|
|
122
|
-
.optional()
|
|
123
|
-
.describe('Timezone (e.g., "America/New_York", "Asia/Kolkata"). Defaults to system local time.'),
|
|
124
|
-
}),
|
|
125
|
-
execute: async ({ timezone }) => {
|
|
126
|
-
try {
|
|
127
|
-
const now = new Date();
|
|
128
|
-
if (timezone) {
|
|
129
|
-
return {
|
|
130
|
-
success: true,
|
|
131
|
-
time: now.toLocaleString("en-US", { timeZone: timezone }),
|
|
132
|
-
timezone: timezone,
|
|
133
|
-
iso: now.toISOString(),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
success: true,
|
|
138
|
-
time: now.toLocaleString(),
|
|
139
|
-
iso: now.toISOString(),
|
|
140
|
-
timestamp: now.getTime(),
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
return {
|
|
145
|
-
success: false,
|
|
146
|
-
error: error instanceof Error ? error.message : String(error),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
}),
|
|
151
|
-
readFile: tool({
|
|
152
|
-
description: "Read the contents of a file from the filesystem",
|
|
195
|
+
function createReadFileTool(policy) {
|
|
196
|
+
return tool({
|
|
197
|
+
description: withRoots("Read the contents of a file from the filesystem", policy),
|
|
153
198
|
inputSchema: z.object({
|
|
154
199
|
path: z.string().describe("File path to read (relative or absolute)"),
|
|
155
200
|
}),
|
|
156
201
|
execute: async ({ path: filePath }) => {
|
|
157
202
|
try {
|
|
158
|
-
// Sandbox: contain reads to the
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if ("error" in guard) {
|
|
203
|
+
// Sandbox: contain reads to the tool roots (symlink-aware).
|
|
204
|
+
const guard = resolveWithinFileToolRoots(filePath, policy);
|
|
205
|
+
if (guard.error !== undefined) {
|
|
162
206
|
return { success: false, error: guard.error };
|
|
163
207
|
}
|
|
164
208
|
const resolvedPath = guard.path;
|
|
165
|
-
const
|
|
209
|
+
const bytes = readWithoutFollowingLinks(resolvedPath);
|
|
210
|
+
const binaryFormat = detectBinaryDocumentFormat(bytes);
|
|
211
|
+
if (binaryFormat) {
|
|
212
|
+
return {
|
|
213
|
+
success: false,
|
|
214
|
+
error: `${filePath} is a binary ${binaryFormat} document, so its bytes are not readable as text. Read it with a parser or converter for that format (for example from a shell command) instead of readFile.`,
|
|
215
|
+
binaryDocument: true,
|
|
216
|
+
format: binaryFormat,
|
|
217
|
+
size: bytes.length,
|
|
218
|
+
path: resolvedPath,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const content = bytes.toString("utf-8");
|
|
166
222
|
const stats = fs.statSync(resolvedPath);
|
|
167
223
|
return {
|
|
168
224
|
success: true,
|
|
@@ -180,9 +236,11 @@ export const directAgentTools = {
|
|
|
180
236
|
};
|
|
181
237
|
}
|
|
182
238
|
},
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function createListDirectoryTool(policy) {
|
|
242
|
+
return tool({
|
|
243
|
+
description: withRoots("List files and directories in a specified directory", policy),
|
|
186
244
|
inputSchema: z.object({
|
|
187
245
|
path: z
|
|
188
246
|
.string()
|
|
@@ -195,9 +253,9 @@ export const directAgentTools = {
|
|
|
195
253
|
}),
|
|
196
254
|
execute: async ({ path: dirPath, includeHidden }) => {
|
|
197
255
|
try {
|
|
198
|
-
// Sandbox: contain directory listing to the
|
|
199
|
-
const guard =
|
|
200
|
-
if (
|
|
256
|
+
// Sandbox: contain directory listing to the tool roots.
|
|
257
|
+
const guard = resolveWithinFileToolRoots(dirPath, policy);
|
|
258
|
+
if (guard.error !== undefined) {
|
|
201
259
|
return { success: false, error: guard.error };
|
|
202
260
|
}
|
|
203
261
|
const resolvedPath = guard.path;
|
|
@@ -230,82 +288,11 @@ export const directAgentTools = {
|
|
|
230
288
|
};
|
|
231
289
|
}
|
|
232
290
|
},
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.string()
|
|
239
|
-
.describe('Mathematical expression to evaluate (e.g., "2 + 2", "Math.sqrt(16)")'),
|
|
240
|
-
precision: z
|
|
241
|
-
.number()
|
|
242
|
-
.optional()
|
|
243
|
-
.describe("Number of decimal places for result")
|
|
244
|
-
.default(2),
|
|
245
|
-
}),
|
|
246
|
-
execute: async ({ expression, precision }) => {
|
|
247
|
-
try {
|
|
248
|
-
// Simple safe evaluation - only allow basic math operations
|
|
249
|
-
const sanitizedExpression = expression.replace(/[^0-9+\-*/().\s]/g, "");
|
|
250
|
-
if (sanitizedExpression !== expression) {
|
|
251
|
-
// Try Math functions for more complex operations
|
|
252
|
-
const allowedMathFunctions = [
|
|
253
|
-
"Math.abs",
|
|
254
|
-
"Math.ceil",
|
|
255
|
-
"Math.floor",
|
|
256
|
-
"Math.round",
|
|
257
|
-
"Math.sqrt",
|
|
258
|
-
"Math.pow",
|
|
259
|
-
"Math.sin",
|
|
260
|
-
"Math.cos",
|
|
261
|
-
"Math.tan",
|
|
262
|
-
"Math.log",
|
|
263
|
-
"Math.exp",
|
|
264
|
-
"Math.PI",
|
|
265
|
-
"Math.E",
|
|
266
|
-
];
|
|
267
|
-
let safeExpression = expression;
|
|
268
|
-
for (const func of allowedMathFunctions) {
|
|
269
|
-
safeExpression = safeExpression.replace(new RegExp(func, "g"), func);
|
|
270
|
-
}
|
|
271
|
-
// Remove remaining non-safe characters except Math functions
|
|
272
|
-
const mathSafe = /^[0-9+\-*/().\s]|Math\.(abs|ceil|floor|round|sqrt|pow|sin|cos|tan|log|exp|PI|E)/g;
|
|
273
|
-
if (!safeExpression
|
|
274
|
-
.split("")
|
|
275
|
-
.every((char) => mathSafe.test(char) ||
|
|
276
|
-
char === "(" ||
|
|
277
|
-
char === ")" ||
|
|
278
|
-
char === "," ||
|
|
279
|
-
char === " ")) {
|
|
280
|
-
return {
|
|
281
|
-
success: false,
|
|
282
|
-
error: `Unsafe expression: Only basic math operations and Math functions are allowed`,
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
// Use Function constructor for safe evaluation
|
|
287
|
-
const result = new Function(`'use strict'; return (${expression})`)();
|
|
288
|
-
const roundedResult = typeof result === "number"
|
|
289
|
-
? Number(result.toFixed(precision))
|
|
290
|
-
: result;
|
|
291
|
-
return {
|
|
292
|
-
success: true,
|
|
293
|
-
expression,
|
|
294
|
-
result: roundedResult,
|
|
295
|
-
type: typeof result,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
catch (error) {
|
|
299
|
-
return {
|
|
300
|
-
success: false,
|
|
301
|
-
error: error instanceof Error ? error.message : String(error),
|
|
302
|
-
expression,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
},
|
|
306
|
-
}),
|
|
307
|
-
writeFile: tool({
|
|
308
|
-
description: "Write content to a file (use with caution)",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
function createWriteFileTool(policy) {
|
|
294
|
+
return tool({
|
|
295
|
+
description: withRoots("Write content to a file (use with caution)", policy),
|
|
309
296
|
inputSchema: z.object({
|
|
310
297
|
path: z.string().describe("File path to write to"),
|
|
311
298
|
content: z.string().describe("Content to write to the file"),
|
|
@@ -316,10 +303,9 @@ export const directAgentTools = {
|
|
|
316
303
|
}),
|
|
317
304
|
execute: async ({ path: filePath, content, mode }) => {
|
|
318
305
|
try {
|
|
319
|
-
// Sandbox: contain writes to the
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if ("error" in guard) {
|
|
306
|
+
// Sandbox: contain writes to the tool roots (symlink-aware).
|
|
307
|
+
const guard = resolveWithinFileToolRoots(filePath, policy);
|
|
308
|
+
if (guard.error !== undefined) {
|
|
323
309
|
return { success: false, error: guard.error };
|
|
324
310
|
}
|
|
325
311
|
const resolvedPath = guard.path;
|
|
@@ -330,12 +316,7 @@ export const directAgentTools = {
|
|
|
330
316
|
error: `File already exists. Use 'overwrite' or 'append' mode to modify existing files.`,
|
|
331
317
|
};
|
|
332
318
|
}
|
|
333
|
-
|
|
334
|
-
if (mode === "append" && fs.existsSync(resolvedPath)) {
|
|
335
|
-
const existingContent = fs.readFileSync(resolvedPath, "utf-8");
|
|
336
|
-
finalContent = existingContent + content;
|
|
337
|
-
}
|
|
338
|
-
fs.writeFileSync(resolvedPath, finalContent, "utf-8");
|
|
319
|
+
writeWithoutFollowingLinks(resolvedPath, content, mode);
|
|
339
320
|
const stats = fs.statSync(resolvedPath);
|
|
340
321
|
return {
|
|
341
322
|
success: true,
|
|
@@ -353,12 +334,232 @@ export const directAgentTools = {
|
|
|
353
334
|
};
|
|
354
335
|
}
|
|
355
336
|
},
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
analyzeCSV:
|
|
361
|
-
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
async function runAnalyzeCSV({ filePath, operation, column, maxRows = 1000 }, policy) {
|
|
340
|
+
const startTime = Date.now();
|
|
341
|
+
logger.info(`[analyzeCSV] 🚀 START: file=${filePath}, operation=${operation}, column=${column}, maxRows=${maxRows}`);
|
|
342
|
+
try {
|
|
343
|
+
// Resolve file path
|
|
344
|
+
logger.debug(`[analyzeCSV] Resolving file: ${filePath}`);
|
|
345
|
+
// Sandbox: contain CSV reads to the tool roots.
|
|
346
|
+
const guard = resolveWithinFileToolRoots(filePath, policy);
|
|
347
|
+
if (guard.error !== undefined) {
|
|
348
|
+
return { success: false, error: guard.error };
|
|
349
|
+
}
|
|
350
|
+
const resolvedPath = guard.path;
|
|
351
|
+
logger.debug(`[analyzeCSV] Resolved path: ${resolvedPath}`);
|
|
352
|
+
// Parse CSV using streaming from disk (memory efficient)
|
|
353
|
+
logger.info(`[analyzeCSV] Starting CSV parsing (max ${maxRows} rows)...`);
|
|
354
|
+
// #384: parseCSVFile now returns validated Record<string, string |
|
|
355
|
+
// undefined>[] rows, so the previous unchecked cast is unnecessary.
|
|
356
|
+
const rows = await CSVProcessor.parseCSVFile(resolvedPath, maxRows);
|
|
357
|
+
logger.info(`[analyzeCSV] ✅ CSV parsing complete: ${rows.length} rows`);
|
|
358
|
+
if (rows.length === 0) {
|
|
359
|
+
logger.warn(`[analyzeCSV] No data rows found`);
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
error: "No data rows found in CSV",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
// Log column names
|
|
366
|
+
const columnNames = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
367
|
+
logger.info(`[analyzeCSV] Found ${rows.length} rows with columns:`, columnNames);
|
|
368
|
+
logger.info(`[analyzeCSV] Executing operation: ${operation}`);
|
|
369
|
+
let result;
|
|
370
|
+
switch (operation) {
|
|
371
|
+
case "count_by_column": {
|
|
372
|
+
logger.info(`[analyzeCSV] count_by_column: column=${column}`);
|
|
373
|
+
if (!column) {
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
error: "Column name required for count_by_column operation",
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// Count occurrences of each value in the column
|
|
380
|
+
const counts = {};
|
|
381
|
+
logger.debug(`[analyzeCSV] Counting rows...`);
|
|
382
|
+
for (const row of rows) {
|
|
383
|
+
const value = row[column];
|
|
384
|
+
if (value !== undefined) {
|
|
385
|
+
counts[value] = (counts[value] || 0) + 1;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
logger.debug(`[analyzeCSV] Found ${Object.keys(counts).length} unique values`);
|
|
389
|
+
// Sort by count descending
|
|
390
|
+
logger.debug(`[analyzeCSV] Sorting results...`);
|
|
391
|
+
result = Object.fromEntries(Object.entries(counts).sort(([, a], [, b]) => b - a));
|
|
392
|
+
logger.info(`[analyzeCSV] ✅ count_by_column complete. Result:`, result);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case "sum_by_column": {
|
|
396
|
+
logger.info(`[analyzeCSV] sum_by_column: column=${column}`);
|
|
397
|
+
if (!column) {
|
|
398
|
+
return {
|
|
399
|
+
success: false,
|
|
400
|
+
error: "Column name required for sum_by_column operation",
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
// Sum numeric values from the target column itself for each group
|
|
404
|
+
const groups = {};
|
|
405
|
+
logger.debug(`[analyzeCSV] Grouping and summing ${rows.length} rows...`);
|
|
406
|
+
let processedRows = 0;
|
|
407
|
+
let totalNumericValuesFound = 0;
|
|
408
|
+
for (const row of rows) {
|
|
409
|
+
const key = row[column];
|
|
410
|
+
if (!key) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// Parse numeric value from the target column
|
|
414
|
+
const value = row[column];
|
|
415
|
+
if (value === undefined || value === null || value === "") {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const num = parseFloat(value);
|
|
419
|
+
if (isNaN(num)) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (!groups[key]) {
|
|
423
|
+
groups[key] = 0;
|
|
424
|
+
}
|
|
425
|
+
groups[key] += num;
|
|
426
|
+
totalNumericValuesFound++;
|
|
427
|
+
processedRows++;
|
|
428
|
+
if (processedRows % 10 === 0) {
|
|
429
|
+
logger.debug(`[analyzeCSV] Processed ${processedRows}/${rows.length} rows`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Fail fast if no numeric data found in the requested column
|
|
433
|
+
if (totalNumericValuesFound === 0) {
|
|
434
|
+
return {
|
|
435
|
+
success: false,
|
|
436
|
+
error: `No numeric data found in column "${column}" for sum_by_column operation`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
logger.debug(`[analyzeCSV] Calculated sums for ${Object.keys(groups).length} groups (${totalNumericValuesFound} numeric values)`);
|
|
440
|
+
result = groups;
|
|
441
|
+
logger.info(`[analyzeCSV] ✅ sum_by_column complete`);
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case "average_by_column": {
|
|
445
|
+
logger.info(`[analyzeCSV] average_by_column: column=${column}`);
|
|
446
|
+
if (!column) {
|
|
447
|
+
return {
|
|
448
|
+
success: false,
|
|
449
|
+
error: "Column name required for average_by_column operation",
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
// Average numeric values from the target column itself for each group
|
|
453
|
+
const groups = {};
|
|
454
|
+
logger.debug(`[analyzeCSV] Grouping and averaging ${rows.length} rows...`);
|
|
455
|
+
let processedRows = 0;
|
|
456
|
+
let totalNumericValuesFound = 0;
|
|
457
|
+
for (const row of rows) {
|
|
458
|
+
const key = row[column];
|
|
459
|
+
if (!key) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
// Parse numeric value from the target column
|
|
463
|
+
const value = row[column];
|
|
464
|
+
if (value === undefined || value === null || value === "") {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const num = parseFloat(value);
|
|
468
|
+
if (isNaN(num)) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!groups[key]) {
|
|
472
|
+
groups[key] = { sum: 0, count: 0 };
|
|
473
|
+
}
|
|
474
|
+
groups[key].sum += num;
|
|
475
|
+
groups[key].count++;
|
|
476
|
+
totalNumericValuesFound++;
|
|
477
|
+
processedRows++;
|
|
478
|
+
if (processedRows % 10 === 0) {
|
|
479
|
+
logger.debug(`[analyzeCSV] Processed ${processedRows}/${rows.length} rows`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Fail fast if no numeric data found in the requested column
|
|
483
|
+
if (totalNumericValuesFound === 0) {
|
|
484
|
+
return {
|
|
485
|
+
success: false,
|
|
486
|
+
error: `No numeric data found in column "${column}" for average_by_column operation`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
logger.debug(`[analyzeCSV] Calculated averages for ${Object.keys(groups).length} groups (${totalNumericValuesFound} numeric values)`);
|
|
490
|
+
result = Object.fromEntries(Object.entries(groups).map(([k, v]) => [
|
|
491
|
+
k,
|
|
492
|
+
v.count > 0 ? v.sum / v.count : 0,
|
|
493
|
+
]));
|
|
494
|
+
logger.info(`[analyzeCSV] ✅ average_by_column complete`);
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case "min_max_by_column": {
|
|
498
|
+
if (!column) {
|
|
499
|
+
return {
|
|
500
|
+
success: false,
|
|
501
|
+
error: "Column name required for min_max_by_column operation",
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const values = rows
|
|
505
|
+
.map((row) => row[column])
|
|
506
|
+
.filter((v) => v !== undefined && v !== "");
|
|
507
|
+
const numericValues = values
|
|
508
|
+
.map((v) => parseFloat(v))
|
|
509
|
+
.filter((n) => !isNaN(n));
|
|
510
|
+
if (numericValues.length === 0) {
|
|
511
|
+
return {
|
|
512
|
+
success: false,
|
|
513
|
+
error: `No numeric data found in column "${column}" for min_max_by_column operation`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
result = {
|
|
517
|
+
min: Math.min(...numericValues),
|
|
518
|
+
max: Math.max(...numericValues),
|
|
519
|
+
numericCount: numericValues.length,
|
|
520
|
+
totalCount: values.length,
|
|
521
|
+
};
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "describe": {
|
|
525
|
+
const columnNames = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
526
|
+
result = {
|
|
527
|
+
total_rows: rows.length,
|
|
528
|
+
columns: columnNames,
|
|
529
|
+
column_count: columnNames.length,
|
|
530
|
+
};
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
default:
|
|
534
|
+
return {
|
|
535
|
+
success: false,
|
|
536
|
+
error: `Unknown operation: ${operation}`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const duration = Date.now() - startTime;
|
|
540
|
+
logger.info(`[analyzeCSV] 🏁 COMPLETE: ${operation} took ${duration}ms`);
|
|
541
|
+
const response = {
|
|
542
|
+
success: true,
|
|
543
|
+
operation,
|
|
544
|
+
column,
|
|
545
|
+
result: JSON.stringify(result, null, 2),
|
|
546
|
+
rowCount: rows.length,
|
|
547
|
+
};
|
|
548
|
+
logger.debug(`[analyzeCSV] 📤 RETURNING TO LLM:`, JSON.stringify(response, null, 2));
|
|
549
|
+
return response;
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
return {
|
|
553
|
+
success: false,
|
|
554
|
+
error: error instanceof Error ? error.message : String(error),
|
|
555
|
+
operation,
|
|
556
|
+
column,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function createAnalyzeCsvTool(policy) {
|
|
561
|
+
return tool({
|
|
562
|
+
description: withRoots("Analyze CSV file for accurate counting, aggregation, and statistical analysis. Use this for precise data operations like counting rows by column, calculating sums/averages, finding min/max values, etc. The tool reads the file directly - do NOT pass CSV content.", policy),
|
|
362
563
|
inputSchema: z.object({
|
|
363
564
|
filePath: z
|
|
364
565
|
.string()
|
|
@@ -380,9 +581,10 @@ export const directAgentTools = {
|
|
|
380
581
|
"c:/program files/",
|
|
381
582
|
"c:/programdata/",
|
|
382
583
|
];
|
|
383
|
-
return !sensitivePatterns.some((pattern) => normalizedPath.startsWith(pattern))
|
|
584
|
+
return (!sensitivePatterns.some((pattern) => normalizedPath.startsWith(pattern)) &&
|
|
585
|
+
resolveWithinFileToolRoots(inputPath, policy).path !== undefined);
|
|
384
586
|
}, {
|
|
385
|
-
message: "Invalid file path: access to system directories is not allowed",
|
|
587
|
+
message: "Invalid file path: access to system directories or paths outside the tool roots is not allowed",
|
|
386
588
|
})
|
|
387
589
|
.describe("Path to the CSV file to analyze (e.g., 'test/data.csv' or '/absolute/path/file.csv')"),
|
|
388
590
|
operation: z
|
|
@@ -405,231 +607,126 @@ export const directAgentTools = {
|
|
|
405
607
|
.default(1000)
|
|
406
608
|
.describe("Maximum rows to process (default: 1000)"),
|
|
407
609
|
}),
|
|
408
|
-
execute: async (
|
|
409
|
-
|
|
410
|
-
|
|
610
|
+
execute: async (args) => runAnalyzeCSV(args, policy),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
function createFileTools(policy) {
|
|
614
|
+
return {
|
|
615
|
+
readFile: createReadFileTool(policy),
|
|
616
|
+
listDirectory: createListDirectoryTool(policy),
|
|
617
|
+
writeFile: createWriteFileTool(policy),
|
|
618
|
+
analyzeCSV: createAnalyzeCsvTool(policy),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/** Tools that never touch the filesystem, shared by every root policy. */
|
|
622
|
+
const policyFreeTools = {
|
|
623
|
+
getCurrentTime: tool({
|
|
624
|
+
description: "Get the current date and time",
|
|
625
|
+
inputSchema: z.object({
|
|
626
|
+
timezone: z
|
|
627
|
+
.string()
|
|
628
|
+
.optional()
|
|
629
|
+
.describe('Timezone (e.g., "America/New_York", "Asia/Kolkata"). Defaults to system local time.'),
|
|
630
|
+
}),
|
|
631
|
+
execute: async ({ timezone }) => {
|
|
411
632
|
try {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
// Sandbox: contain CSV reads to the working directory.
|
|
415
|
-
const guard = resolveWithinCwd(filePath);
|
|
416
|
-
if ("error" in guard) {
|
|
417
|
-
return { success: false, error: guard.error };
|
|
418
|
-
}
|
|
419
|
-
const resolvedPath = guard.path;
|
|
420
|
-
logger.debug(`[analyzeCSV] Resolved path: ${resolvedPath}`);
|
|
421
|
-
// Parse CSV using streaming from disk (memory efficient)
|
|
422
|
-
logger.info(`[analyzeCSV] Starting CSV parsing (max ${maxRows} rows)...`);
|
|
423
|
-
// #384: parseCSVFile now returns validated Record<string, string |
|
|
424
|
-
// undefined>[] rows, so the previous unchecked cast is unnecessary.
|
|
425
|
-
const rows = await CSVProcessor.parseCSVFile(resolvedPath, maxRows);
|
|
426
|
-
logger.info(`[analyzeCSV] ✅ CSV parsing complete: ${rows.length} rows`);
|
|
427
|
-
if (rows.length === 0) {
|
|
428
|
-
logger.warn(`[analyzeCSV] No data rows found`);
|
|
633
|
+
const now = new Date();
|
|
634
|
+
if (timezone) {
|
|
429
635
|
return {
|
|
430
|
-
success:
|
|
431
|
-
|
|
636
|
+
success: true,
|
|
637
|
+
time: now.toLocaleString("en-US", { timeZone: timezone }),
|
|
638
|
+
timezone: timezone,
|
|
639
|
+
iso: now.toISOString(),
|
|
432
640
|
};
|
|
433
641
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
487
|
-
const num = parseFloat(value);
|
|
488
|
-
if (isNaN(num)) {
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
if (!groups[key]) {
|
|
492
|
-
groups[key] = 0;
|
|
493
|
-
}
|
|
494
|
-
groups[key] += num;
|
|
495
|
-
totalNumericValuesFound++;
|
|
496
|
-
processedRows++;
|
|
497
|
-
if (processedRows % 10 === 0) {
|
|
498
|
-
logger.debug(`[analyzeCSV] Processed ${processedRows}/${rows.length} rows`);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
// Fail fast if no numeric data found in the requested column
|
|
502
|
-
if (totalNumericValuesFound === 0) {
|
|
503
|
-
return {
|
|
504
|
-
success: false,
|
|
505
|
-
error: `No numeric data found in column "${column}" for sum_by_column operation`,
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
logger.debug(`[analyzeCSV] Calculated sums for ${Object.keys(groups).length} groups (${totalNumericValuesFound} numeric values)`);
|
|
509
|
-
result = groups;
|
|
510
|
-
logger.info(`[analyzeCSV] ✅ sum_by_column complete`);
|
|
511
|
-
break;
|
|
512
|
-
}
|
|
513
|
-
case "average_by_column": {
|
|
514
|
-
logger.info(`[analyzeCSV] average_by_column: column=${column}`);
|
|
515
|
-
if (!column) {
|
|
516
|
-
return {
|
|
517
|
-
success: false,
|
|
518
|
-
error: "Column name required for average_by_column operation",
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
// Average numeric values from the target column itself for each group
|
|
522
|
-
const groups = {};
|
|
523
|
-
logger.debug(`[analyzeCSV] Grouping and averaging ${rows.length} rows...`);
|
|
524
|
-
let processedRows = 0;
|
|
525
|
-
let totalNumericValuesFound = 0;
|
|
526
|
-
for (const row of rows) {
|
|
527
|
-
const key = row[column];
|
|
528
|
-
if (!key) {
|
|
529
|
-
continue;
|
|
530
|
-
}
|
|
531
|
-
// Parse numeric value from the target column
|
|
532
|
-
const value = row[column];
|
|
533
|
-
if (value === undefined || value === null || value === "") {
|
|
534
|
-
continue;
|
|
535
|
-
}
|
|
536
|
-
const num = parseFloat(value);
|
|
537
|
-
if (isNaN(num)) {
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
if (!groups[key]) {
|
|
541
|
-
groups[key] = { sum: 0, count: 0 };
|
|
542
|
-
}
|
|
543
|
-
groups[key].sum += num;
|
|
544
|
-
groups[key].count++;
|
|
545
|
-
totalNumericValuesFound++;
|
|
546
|
-
processedRows++;
|
|
547
|
-
if (processedRows % 10 === 0) {
|
|
548
|
-
logger.debug(`[analyzeCSV] Processed ${processedRows}/${rows.length} rows`);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
// Fail fast if no numeric data found in the requested column
|
|
552
|
-
if (totalNumericValuesFound === 0) {
|
|
553
|
-
return {
|
|
554
|
-
success: false,
|
|
555
|
-
error: `No numeric data found in column "${column}" for average_by_column operation`,
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
|
-
logger.debug(`[analyzeCSV] Calculated averages for ${Object.keys(groups).length} groups (${totalNumericValuesFound} numeric values)`);
|
|
559
|
-
result = Object.fromEntries(Object.entries(groups).map(([k, v]) => [
|
|
560
|
-
k,
|
|
561
|
-
v.count > 0 ? v.sum / v.count : 0,
|
|
562
|
-
]));
|
|
563
|
-
logger.info(`[analyzeCSV] ✅ average_by_column complete`);
|
|
564
|
-
break;
|
|
565
|
-
}
|
|
566
|
-
case "min_max_by_column": {
|
|
567
|
-
if (!column) {
|
|
568
|
-
return {
|
|
569
|
-
success: false,
|
|
570
|
-
error: "Column name required for min_max_by_column operation",
|
|
571
|
-
};
|
|
572
|
-
}
|
|
573
|
-
const values = rows
|
|
574
|
-
.map((row) => row[column])
|
|
575
|
-
.filter((v) => v !== undefined && v !== "");
|
|
576
|
-
const numericValues = values
|
|
577
|
-
.map((v) => parseFloat(v))
|
|
578
|
-
.filter((n) => !isNaN(n));
|
|
579
|
-
if (numericValues.length === 0) {
|
|
580
|
-
return {
|
|
581
|
-
success: false,
|
|
582
|
-
error: `No numeric data found in column "${column}" for min_max_by_column operation`,
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
result = {
|
|
586
|
-
min: Math.min(...numericValues),
|
|
587
|
-
max: Math.max(...numericValues),
|
|
588
|
-
numericCount: numericValues.length,
|
|
589
|
-
totalCount: values.length,
|
|
590
|
-
};
|
|
591
|
-
break;
|
|
592
|
-
}
|
|
593
|
-
case "describe": {
|
|
594
|
-
const columnNames = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
595
|
-
result = {
|
|
596
|
-
total_rows: rows.length,
|
|
597
|
-
columns: columnNames,
|
|
598
|
-
column_count: columnNames.length,
|
|
599
|
-
};
|
|
600
|
-
break;
|
|
642
|
+
return {
|
|
643
|
+
success: true,
|
|
644
|
+
time: now.toLocaleString(),
|
|
645
|
+
iso: now.toISOString(),
|
|
646
|
+
timestamp: now.getTime(),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
return {
|
|
651
|
+
success: false,
|
|
652
|
+
error: error instanceof Error ? error.message : String(error),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
}),
|
|
657
|
+
calculateMath: tool({
|
|
658
|
+
description: "Perform mathematical calculations safely",
|
|
659
|
+
inputSchema: z.object({
|
|
660
|
+
expression: z
|
|
661
|
+
.string()
|
|
662
|
+
.describe('Mathematical expression to evaluate (e.g., "2 + 2", "Math.sqrt(16)")'),
|
|
663
|
+
precision: z
|
|
664
|
+
.number()
|
|
665
|
+
.optional()
|
|
666
|
+
.describe("Number of decimal places for result")
|
|
667
|
+
.default(2),
|
|
668
|
+
}),
|
|
669
|
+
execute: async ({ expression, precision }) => {
|
|
670
|
+
try {
|
|
671
|
+
// Simple safe evaluation - only allow basic math operations
|
|
672
|
+
const sanitizedExpression = expression.replace(/[^0-9+\-*/().\s]/g, "");
|
|
673
|
+
if (sanitizedExpression !== expression) {
|
|
674
|
+
// Try Math functions for more complex operations
|
|
675
|
+
const allowedMathFunctions = [
|
|
676
|
+
"Math.abs",
|
|
677
|
+
"Math.ceil",
|
|
678
|
+
"Math.floor",
|
|
679
|
+
"Math.round",
|
|
680
|
+
"Math.sqrt",
|
|
681
|
+
"Math.pow",
|
|
682
|
+
"Math.sin",
|
|
683
|
+
"Math.cos",
|
|
684
|
+
"Math.tan",
|
|
685
|
+
"Math.log",
|
|
686
|
+
"Math.exp",
|
|
687
|
+
"Math.PI",
|
|
688
|
+
"Math.E",
|
|
689
|
+
];
|
|
690
|
+
let safeExpression = expression;
|
|
691
|
+
for (const func of allowedMathFunctions) {
|
|
692
|
+
safeExpression = safeExpression.replace(new RegExp(func, "g"), func);
|
|
601
693
|
}
|
|
602
|
-
|
|
694
|
+
// Remove remaining non-safe characters except Math functions
|
|
695
|
+
const mathSafe = /^[0-9+\-*/().\s]|Math\.(abs|ceil|floor|round|sqrt|pow|sin|cos|tan|log|exp|PI|E)/g;
|
|
696
|
+
if (!safeExpression
|
|
697
|
+
.split("")
|
|
698
|
+
.every((char) => mathSafe.test(char) ||
|
|
699
|
+
char === "(" ||
|
|
700
|
+
char === ")" ||
|
|
701
|
+
char === "," ||
|
|
702
|
+
char === " ")) {
|
|
603
703
|
return {
|
|
604
704
|
success: false,
|
|
605
|
-
error: `
|
|
705
|
+
error: `Unsafe expression: Only basic math operations and Math functions are allowed`,
|
|
606
706
|
};
|
|
707
|
+
}
|
|
607
708
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const
|
|
709
|
+
// Use Function constructor for safe evaluation
|
|
710
|
+
const result = new Function(`'use strict'; return (${expression})`)();
|
|
711
|
+
const roundedResult = typeof result === "number"
|
|
712
|
+
? Number(result.toFixed(precision))
|
|
713
|
+
: result;
|
|
714
|
+
return {
|
|
611
715
|
success: true,
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
rowCount: rows.length,
|
|
716
|
+
expression,
|
|
717
|
+
result: roundedResult,
|
|
718
|
+
type: typeof result,
|
|
616
719
|
};
|
|
617
|
-
logger.debug(`[analyzeCSV] 📤 RETURNING TO LLM:`, JSON.stringify(response, null, 2));
|
|
618
|
-
return response;
|
|
619
720
|
}
|
|
620
721
|
catch (error) {
|
|
621
722
|
return {
|
|
622
723
|
success: false,
|
|
623
724
|
error: error instanceof Error ? error.message : String(error),
|
|
624
|
-
|
|
625
|
-
column,
|
|
725
|
+
expression,
|
|
626
726
|
};
|
|
627
727
|
}
|
|
628
728
|
},
|
|
629
729
|
}),
|
|
630
|
-
// NOTE: executeBashCommand was moved to a separate opt-in export (bashTool) for security.
|
|
631
|
-
// It is only included in directAgentTools when NEUROLINK_ENABLE_BASH_TOOL=true or
|
|
632
|
-
// toolConfig.enableBashTool is explicitly set to true. See shouldEnableBashTool() in toolUtils.ts.
|
|
633
730
|
websearchGrounding: tool({
|
|
634
731
|
description: "Performs a Google Search and returns a summarized answer with source citations. Always check the current date before constructing the query. Use whenever the answer depends on time-sensitive facts or requires verification against real-world sources.",
|
|
635
732
|
inputSchema: z.object({
|
|
@@ -735,6 +832,32 @@ export const directAgentTools = {
|
|
|
735
832
|
},
|
|
736
833
|
}),
|
|
737
834
|
};
|
|
835
|
+
/**
|
|
836
|
+
* Direct tool definitions that work immediately with Gemini/AI SDK.
|
|
837
|
+
* These bypass MCP complexity and provide reliable agent functionality.
|
|
838
|
+
*
|
|
839
|
+
* `createDirectAgentTools` builds a fresh set bound to `policy`; the shared
|
|
840
|
+
* `directAgentTools` below keeps the working-directory default.
|
|
841
|
+
*/
|
|
842
|
+
export function createDirectAgentTools(policy) {
|
|
843
|
+
const fileTools = createFileTools(policy);
|
|
844
|
+
const tools = {
|
|
845
|
+
getCurrentTime: policyFreeTools.getCurrentTime,
|
|
846
|
+
readFile: fileTools.readFile,
|
|
847
|
+
listDirectory: fileTools.listDirectory,
|
|
848
|
+
calculateMath: policyFreeTools.calculateMath,
|
|
849
|
+
writeFile: fileTools.writeFile,
|
|
850
|
+
analyzeCSV: fileTools.analyzeCSV,
|
|
851
|
+
websearchGrounding: policyFreeTools.websearchGrounding,
|
|
852
|
+
};
|
|
853
|
+
// executeBashCommand is opt-in: only included when NEUROLINK_ENABLE_BASH_TOOL=true
|
|
854
|
+
// or toolConfig.enableBashTool is set. See shouldEnableBashTool() in toolUtils.ts.
|
|
855
|
+
if (shouldEnableBashTool()) {
|
|
856
|
+
tools.executeBashCommand =
|
|
857
|
+
createBashTool(policy);
|
|
858
|
+
}
|
|
859
|
+
return tools;
|
|
860
|
+
}
|
|
738
861
|
/**
|
|
739
862
|
* Bash command execution tool - exported separately for opt-in use.
|
|
740
863
|
*
|
|
@@ -746,105 +869,114 @@ export const directAgentTools = {
|
|
|
746
869
|
* Import this directly when you need bash execution capabilities:
|
|
747
870
|
* import { bashTool } from '../agent/directTools.js';
|
|
748
871
|
*/
|
|
749
|
-
export
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const currentCwd = process.cwd();
|
|
770
|
-
// Verify cwd exists before resolving symlinks
|
|
771
|
-
if (!fs.existsSync(resolvedCwd) ||
|
|
772
|
-
!fs.statSync(resolvedCwd).isDirectory()) {
|
|
773
|
-
return {
|
|
774
|
-
success: false,
|
|
775
|
-
code: -1,
|
|
776
|
-
stdout: "",
|
|
777
|
-
stderr: "",
|
|
778
|
-
error: `Directory does not exist: ${resolvedCwd}`,
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
// Security: resolve symlinks and prevent execution outside current directory
|
|
872
|
+
export function createBashTool(policy) {
|
|
873
|
+
return tool({
|
|
874
|
+
description: "Execute a bash/shell command and return stdout, stderr, and exit code. Supports full shell syntax including pipes, redirects, and variable expansion. Requires HITL confirmation when enabled. The command runs with the full privileges of the process: tool roots constrain only its starting directory (cwd), not where the command itself can reach.",
|
|
875
|
+
inputSchema: z.object({
|
|
876
|
+
command: z
|
|
877
|
+
.string()
|
|
878
|
+
.describe("The shell command to execute (supports pipes, redirects, etc.)"),
|
|
879
|
+
timeout: z
|
|
880
|
+
.number()
|
|
881
|
+
.optional()
|
|
882
|
+
.default(30000)
|
|
883
|
+
.describe("Timeout in milliseconds (default: 30000, max: 120000)"),
|
|
884
|
+
cwd: z
|
|
885
|
+
.string()
|
|
886
|
+
.optional()
|
|
887
|
+
.describe(policy.roots === null
|
|
888
|
+
? "Working directory (defaults to process.cwd())"
|
|
889
|
+
: `Working directory; must be inside one of: ${policy.roots.join(", ")}`),
|
|
890
|
+
}),
|
|
891
|
+
execute: async ({ command, timeout = 30000, cwd }) => {
|
|
782
892
|
try {
|
|
783
|
-
const
|
|
784
|
-
const
|
|
785
|
-
if (
|
|
893
|
+
const effectiveTimeout = Math.min(Math.max(timeout, 100), 120000);
|
|
894
|
+
const roots = rootsOf(policy);
|
|
895
|
+
if (roots.length === 0) {
|
|
896
|
+
return {
|
|
897
|
+
success: false,
|
|
898
|
+
code: -1,
|
|
899
|
+
stdout: "",
|
|
900
|
+
stderr: "",
|
|
901
|
+
error: "Access denied: no tool roots are configured, so there is no permitted working directory",
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const requestedCwd = cwd ? path.resolve(roots[0], cwd) : roots[0];
|
|
905
|
+
// Verify cwd exists before resolving symlinks
|
|
906
|
+
if (!fs.existsSync(requestedCwd) ||
|
|
907
|
+
!fs.statSync(requestedCwd).isDirectory()) {
|
|
908
|
+
return {
|
|
909
|
+
success: false,
|
|
910
|
+
code: -1,
|
|
911
|
+
stdout: "",
|
|
912
|
+
stderr: "",
|
|
913
|
+
error: `Directory does not exist: ${requestedCwd}`,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
// Security: resolve symlinks on both sides and require a separator-bounded
|
|
917
|
+
// prefix, so neither a symlink nor a sibling like <root>-evil escapes.
|
|
918
|
+
let resolvedCwd;
|
|
919
|
+
for (const root of roots) {
|
|
920
|
+
const contained = resolveWithinRoot(requestedCwd, root);
|
|
921
|
+
if (contained.path !== undefined) {
|
|
922
|
+
resolvedCwd = contained.path;
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (resolvedCwd === undefined) {
|
|
786
927
|
return {
|
|
787
928
|
success: false,
|
|
788
929
|
code: -1,
|
|
789
930
|
stdout: "",
|
|
790
931
|
stderr: "",
|
|
791
|
-
error:
|
|
932
|
+
error: policy.roots === null
|
|
933
|
+
? "Access denied: Cannot execute commands outside current directory"
|
|
934
|
+
: `Access denied: Cannot execute commands outside the permitted roots (${roots.join(", ")})`,
|
|
792
935
|
};
|
|
793
936
|
}
|
|
937
|
+
// Use /bin/bash -c to support full shell syntax (pipes, redirects, etc.)
|
|
938
|
+
return await new Promise((resolve) => {
|
|
939
|
+
execFile("/bin/bash", ["-c", command], {
|
|
940
|
+
timeout: effectiveTimeout,
|
|
941
|
+
cwd: resolvedCwd,
|
|
942
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
943
|
+
}, (error, stdout, stderr) => {
|
|
944
|
+
if (error) {
|
|
945
|
+
const exitCode = typeof error.code === "number" ? error.code : 1;
|
|
946
|
+
resolve({
|
|
947
|
+
success: false,
|
|
948
|
+
code: exitCode,
|
|
949
|
+
stdout: truncateOutput(stdout || ""),
|
|
950
|
+
stderr: truncateOutput(stderr || error.message),
|
|
951
|
+
error: error.killed ? "Command timed out" : error.message,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
resolve({
|
|
956
|
+
success: true,
|
|
957
|
+
code: 0,
|
|
958
|
+
stdout: truncateOutput(stdout),
|
|
959
|
+
stderr: truncateOutput(stderr),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
});
|
|
794
964
|
}
|
|
795
|
-
catch {
|
|
965
|
+
catch (error) {
|
|
796
966
|
return {
|
|
797
967
|
success: false,
|
|
798
968
|
code: -1,
|
|
799
969
|
stdout: "",
|
|
800
970
|
stderr: "",
|
|
801
|
-
error:
|
|
971
|
+
error: error instanceof Error ? error.message : String(error),
|
|
802
972
|
};
|
|
803
973
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
execFile("/bin/bash", ["-c", command], {
|
|
807
|
-
timeout: effectiveTimeout,
|
|
808
|
-
cwd: resolvedCwd,
|
|
809
|
-
maxBuffer: MAX_OUTPUT_BYTES,
|
|
810
|
-
}, (error, stdout, stderr) => {
|
|
811
|
-
if (error) {
|
|
812
|
-
const exitCode = typeof error.code === "number" ? error.code : 1;
|
|
813
|
-
resolve({
|
|
814
|
-
success: false,
|
|
815
|
-
code: exitCode,
|
|
816
|
-
stdout: truncateOutput(stdout || ""),
|
|
817
|
-
stderr: truncateOutput(stderr || error.message),
|
|
818
|
-
error: error.killed ? "Command timed out" : error.message,
|
|
819
|
-
});
|
|
820
|
-
}
|
|
821
|
-
else {
|
|
822
|
-
resolve({
|
|
823
|
-
success: true,
|
|
824
|
-
code: 0,
|
|
825
|
-
stdout: truncateOutput(stdout),
|
|
826
|
-
stderr: truncateOutput(stderr),
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
catch (error) {
|
|
833
|
-
return {
|
|
834
|
-
success: false,
|
|
835
|
-
code: -1,
|
|
836
|
-
stdout: "",
|
|
837
|
-
stderr: "",
|
|
838
|
-
error: error instanceof Error ? error.message : String(error),
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
},
|
|
842
|
-
});
|
|
843
|
-
// Conditionally inject executeBashCommand into directAgentTools when opted in.
|
|
844
|
-
// This ensures the tool is only available to SDK consumers who explicitly enable it.
|
|
845
|
-
if (shouldEnableBashTool()) {
|
|
846
|
-
directAgentTools.executeBashCommand = bashTool;
|
|
974
|
+
},
|
|
975
|
+
});
|
|
847
976
|
}
|
|
977
|
+
/** The opt-in bash tool with the working-directory default. */
|
|
978
|
+
export const bashTool = createBashTool(WORKING_DIRECTORY_POLICY);
|
|
979
|
+
export const directAgentTools = createDirectAgentTools(WORKING_DIRECTORY_POLICY);
|
|
848
980
|
// eslint-disable-next-line no-redeclare
|
|
849
981
|
export function getToolsForCategory(category = "all") {
|
|
850
982
|
switch (category) {
|