ysagc-agent 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -36
- package/dist/agent.d.ts +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +46 -2
- package/dist/agent.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +285 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -1
- package/dist/config.js.map +1 -1
- package/dist/executor.d.ts +3 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +65 -4
- package/dist/executor.js.map +1 -1
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +16 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/skills.d.ts +16 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +105 -0
- package/dist/skills.js.map +1 -0
- package/dist/tokens.d.ts +26 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +50 -0
- package/dist/tokens.js.map +1 -0
- package/dist/tools.d.ts +58 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +959 -10
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -34,11 +34,16 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.ToolError = void 0;
|
|
37
|
+
exports.walkTree = walkTree;
|
|
38
|
+
exports.globToRegex = globToRegex;
|
|
39
|
+
exports.preExecute = preExecute;
|
|
40
|
+
exports.postExecute = postExecute;
|
|
37
41
|
exports.createToolRegistry = createToolRegistry;
|
|
38
42
|
const fs = __importStar(require("node:fs"));
|
|
39
43
|
const path = __importStar(require("node:path"));
|
|
40
44
|
const path_utils_1 = require("./path-utils");
|
|
41
45
|
const security_1 = require("./security");
|
|
46
|
+
const skills_1 = require("./skills");
|
|
42
47
|
const executor_1 = require("./executor");
|
|
43
48
|
/** Thrown when a tool invocation is invalid or unsafe. */
|
|
44
49
|
class ToolError extends Error {
|
|
@@ -92,6 +97,15 @@ function requiredArray(args, key) {
|
|
|
92
97
|
}
|
|
93
98
|
return v;
|
|
94
99
|
}
|
|
100
|
+
function optionalBool(args, key, fallback) {
|
|
101
|
+
const v = args[key];
|
|
102
|
+
if (v === undefined || v === null)
|
|
103
|
+
return fallback;
|
|
104
|
+
if (typeof v !== 'boolean') {
|
|
105
|
+
throw new ToolError('INVALID_ARG', `"${key}" must be a boolean.`);
|
|
106
|
+
}
|
|
107
|
+
return v;
|
|
108
|
+
}
|
|
95
109
|
function abortIfRequested(signal) {
|
|
96
110
|
if (signal?.aborted) {
|
|
97
111
|
throw new ToolError('ABORTED', 'Operation was cancelled.');
|
|
@@ -116,11 +130,11 @@ function isUtf8(buf) {
|
|
|
116
130
|
return false;
|
|
117
131
|
}
|
|
118
132
|
}
|
|
119
|
-
function readUpTo(target, length) {
|
|
133
|
+
function readUpTo(target, length, offset = 0) {
|
|
120
134
|
const fd = fs.openSync(target, 'r');
|
|
121
135
|
try {
|
|
122
136
|
const buf = Buffer.alloc(length);
|
|
123
|
-
const bytesRead = fs.readSync(fd, buf, 0, length,
|
|
137
|
+
const bytesRead = fs.readSync(fd, buf, 0, length, offset);
|
|
124
138
|
return buf.subarray(0, Math.max(0, bytesRead));
|
|
125
139
|
}
|
|
126
140
|
finally {
|
|
@@ -143,6 +157,283 @@ function shellQuote(arg) {
|
|
|
143
157
|
return arg;
|
|
144
158
|
}
|
|
145
159
|
// ---------------------------------------------------------------------------
|
|
160
|
+
// directory walking / glob helpers
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
/** Heavy / generated directories that file walkers skip by default so a large
|
|
163
|
+
* dependency tree does not flood a search or glob result. */
|
|
164
|
+
const DEFAULT_WALK_EXCLUDE = new Set([
|
|
165
|
+
'.git',
|
|
166
|
+
'.hg',
|
|
167
|
+
'.svn',
|
|
168
|
+
'node_modules',
|
|
169
|
+
'dist',
|
|
170
|
+
'build',
|
|
171
|
+
'out',
|
|
172
|
+
'.cache',
|
|
173
|
+
'.npm',
|
|
174
|
+
'.venv',
|
|
175
|
+
'venv',
|
|
176
|
+
'env',
|
|
177
|
+
'__pycache__',
|
|
178
|
+
'.next',
|
|
179
|
+
'.nuxt',
|
|
180
|
+
'.turbo',
|
|
181
|
+
'coverage',
|
|
182
|
+
'.idea',
|
|
183
|
+
'.vs',
|
|
184
|
+
'target',
|
|
185
|
+
'.docusaurus',
|
|
186
|
+
]);
|
|
187
|
+
/**
|
|
188
|
+
* Walk a directory tree depth-first, returning every file and directory as a
|
|
189
|
+
* flat list. Never follows symbolic links (which defends against root escape
|
|
190
|
+
* and symlink cycles), skips the default heavy directories, and bounds the
|
|
191
|
+
* total number of entries returned. Directory entries are guaranteed to be
|
|
192
|
+
* returned before their children.
|
|
193
|
+
*/
|
|
194
|
+
function walkTree(base, opts = {}) {
|
|
195
|
+
const max = opts.maxEntries ?? 5000;
|
|
196
|
+
const exclude = opts.exclude ?? DEFAULT_WALK_EXCLUDE;
|
|
197
|
+
const includeHidden = opts.includeHidden ?? false;
|
|
198
|
+
const out = [];
|
|
199
|
+
const stack = [{ rel: '', abs: base }];
|
|
200
|
+
while (stack.length) {
|
|
201
|
+
const cur = stack.pop();
|
|
202
|
+
let names;
|
|
203
|
+
try {
|
|
204
|
+
names = fs.readdirSync(cur.abs);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
names.sort();
|
|
210
|
+
for (const name of names) {
|
|
211
|
+
if (!includeHidden && name.startsWith('.'))
|
|
212
|
+
continue;
|
|
213
|
+
if (exclude.has(name))
|
|
214
|
+
continue;
|
|
215
|
+
const rel = cur.rel ? `${cur.rel}/${name}` : name;
|
|
216
|
+
const abs = path.join(cur.abs, name);
|
|
217
|
+
let st;
|
|
218
|
+
try {
|
|
219
|
+
st = fs.lstatSync(abs);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (st.isSymbolicLink())
|
|
225
|
+
continue;
|
|
226
|
+
const type = st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other';
|
|
227
|
+
if (type === 'other')
|
|
228
|
+
continue;
|
|
229
|
+
out.push({ rel, abs, type });
|
|
230
|
+
if (out.length >= max)
|
|
231
|
+
return out;
|
|
232
|
+
if (type === 'dir')
|
|
233
|
+
stack.push({ rel, abs });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
/** Whether a file buffer is binary (contains a NUL byte). */
|
|
239
|
+
function isBinary(buf) {
|
|
240
|
+
return buf.includes(0);
|
|
241
|
+
}
|
|
242
|
+
/** Convert a glob pattern (`*`, `?`, `**`, and literals — no char classes)
|
|
243
|
+
* into an anchored regular expression. `**` matches zero or more path
|
|
244
|
+
* segments; `*`/`?` match within a single segment. On Windows the match is
|
|
245
|
+
* case-insensitive to mirror the filesystem. */
|
|
246
|
+
function globToRegex(glob) {
|
|
247
|
+
let out = '^';
|
|
248
|
+
let i = 0;
|
|
249
|
+
const n = glob.length;
|
|
250
|
+
while (i < n) {
|
|
251
|
+
const ch = glob[i];
|
|
252
|
+
if (ch === '*') {
|
|
253
|
+
if (glob[i + 1] === '*') {
|
|
254
|
+
i += 2;
|
|
255
|
+
if (glob[i] === '/') {
|
|
256
|
+
out += '(?:.*/)?';
|
|
257
|
+
i += 1;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
out += '.*';
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
out += '[^/]*';
|
|
265
|
+
i += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (ch === '?') {
|
|
269
|
+
out += '[^/]';
|
|
270
|
+
i += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if ('\\.^$|()[]{}'.includes(ch)) {
|
|
274
|
+
out += '\\' + ch;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
out += ch;
|
|
278
|
+
}
|
|
279
|
+
i += 1;
|
|
280
|
+
}
|
|
281
|
+
out += '$';
|
|
282
|
+
return new RegExp(out, process.platform === 'win32' ? 'i' : '');
|
|
283
|
+
}
|
|
284
|
+
/** Tools that mutate the workspace or execute commands (blocked by read-only). */
|
|
285
|
+
const WRITE_OR_COMMAND_TOOLS = new Set([
|
|
286
|
+
'write_file',
|
|
287
|
+
'edit_file',
|
|
288
|
+
'str_replace_editor',
|
|
289
|
+
'generate',
|
|
290
|
+
'run_command',
|
|
291
|
+
'git',
|
|
292
|
+
'mv',
|
|
293
|
+
'cp',
|
|
294
|
+
'delete_file',
|
|
295
|
+
]);
|
|
296
|
+
/** Reconstruct the shell command a command-tool intends to run, if any. */
|
|
297
|
+
function commandForTool(tool, args) {
|
|
298
|
+
if (tool === 'run_command') {
|
|
299
|
+
return typeof args.cmd === 'string' ? args.cmd : undefined;
|
|
300
|
+
}
|
|
301
|
+
if (tool === 'git') {
|
|
302
|
+
if (typeof args.command === 'string' && args.command.trim() !== '') {
|
|
303
|
+
return `git ${args.command.trim()}`;
|
|
304
|
+
}
|
|
305
|
+
if (Array.isArray(args.args)) {
|
|
306
|
+
return ['git', ...args.args.map((a) => String(a))].join(' ');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
/** Collect the path-bearing argument values for a tool, if any. */
|
|
312
|
+
function pathArgsForTool(tool, args) {
|
|
313
|
+
const keys = ['path', 'cwd'];
|
|
314
|
+
if (tool === 'generate')
|
|
315
|
+
keys.push('name');
|
|
316
|
+
if (tool === 'mv' || tool === 'cp')
|
|
317
|
+
keys.push('source', 'destination');
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const key of keys) {
|
|
320
|
+
const v = args[key];
|
|
321
|
+
if (typeof v === 'string' && v.trim() !== '')
|
|
322
|
+
out.push(v);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
/** Default preset used when the caller did not specify one on the context. */
|
|
327
|
+
const DEFAULT_PRESET = 'workspace-write';
|
|
328
|
+
/** Whether the active preset permits a danger-flagged command without an
|
|
329
|
+
* approval prompt. Only the most-permissive mode allows non-critical danger
|
|
330
|
+
* commands; every other mode blocks them at the gate. */
|
|
331
|
+
function permitsDangerCommand(preset, severity) {
|
|
332
|
+
return preset === 'danger-full-access' && severity !== 'critical';
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The guarded pre-execution hook. Runs before every tool:
|
|
336
|
+
* 1. enforces the permission preset (read-only blocks writes/commands);
|
|
337
|
+
* 2. flags dangerous commands (workspace-write asks for approval, most
|
|
338
|
+
* permissive mode only blocks critical commands, read-only blocks all);
|
|
339
|
+
* 3. canonicalises and validates every path argument (inside-root + sensitive
|
|
340
|
+
* path check).
|
|
341
|
+
* Returns `{ ok: true }` when execution may proceed, otherwise a structured
|
|
342
|
+
* block with an error and optional `needsApproval`.
|
|
343
|
+
*/
|
|
344
|
+
function preExecute(ctx, tool, args) {
|
|
345
|
+
const preset = ctx.permissionPreset ?? DEFAULT_PRESET;
|
|
346
|
+
// 1) Permission preset: read-only denies anything that writes or executes.
|
|
347
|
+
if (preset === 'read-only') {
|
|
348
|
+
if (WRITE_OR_COMMAND_TOOLS.has(tool)) {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
error: `Tool "${tool}" is blocked in read-only mode (preset: read-only).`,
|
|
352
|
+
reason: 'read-only preset denies writes and commands',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// 2) Dangerous-command gate for shell tools.
|
|
357
|
+
const cmd = commandForTool(tool, args);
|
|
358
|
+
if (cmd !== undefined) {
|
|
359
|
+
const assessment = (0, security_1.isDangerCommand)(cmd);
|
|
360
|
+
if (assessment.danger) {
|
|
361
|
+
if (preset === 'danger-full-access' && assessment.severity === 'danger') {
|
|
362
|
+
// Most permissive: allow non-critical danger commands but record why.
|
|
363
|
+
// (critical severity falls through and is blocked below.)
|
|
364
|
+
}
|
|
365
|
+
else if (preset === 'workspace-write') {
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
needsApproval: true,
|
|
369
|
+
error: `Dangerous command requires approval (${assessment.severity}): ${assessment.reason}`,
|
|
370
|
+
reason: assessment.reason,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
error: `Blocked dangerous command (${assessment.severity}): ${assessment.reason}`,
|
|
377
|
+
reason: assessment.reason,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// 3) Canonicalise + sensitive-path check for every path argument.
|
|
383
|
+
let realRoot;
|
|
384
|
+
for (const p of pathArgsForTool(tool, args)) {
|
|
385
|
+
try {
|
|
386
|
+
realRoot = realRoot ?? (0, path_utils_1.canonicalize)(ctx.root);
|
|
387
|
+
const target = (0, path_utils_1.safeResolve)(ctx.root, p, realRoot, { allowEqual: true });
|
|
388
|
+
if ((0, security_1.isSensitivePath)(target)) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
error: `Sensitive path is blocked: ${p}`,
|
|
392
|
+
reason: 'sensitive path',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
if (err instanceof path_utils_1.PathError) {
|
|
398
|
+
return { ok: false, error: err.message, reason: 'path validation failed' };
|
|
399
|
+
}
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return { ok: true };
|
|
404
|
+
}
|
|
405
|
+
/** Deep-mask secrets in a tool result so internal keys never leak out. */
|
|
406
|
+
function maskSecretsDeep(value) {
|
|
407
|
+
if (typeof value === 'string')
|
|
408
|
+
return (0, security_1.maskSecrets)(value);
|
|
409
|
+
if (Array.isArray(value))
|
|
410
|
+
return value.map((v) => maskSecretsDeep(v));
|
|
411
|
+
if (value && typeof value === 'object') {
|
|
412
|
+
const out = {};
|
|
413
|
+
for (const [k, v] of Object.entries(value)) {
|
|
414
|
+
out[k] = maskSecretsDeep(v);
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
return value;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The guarded post-execution hook. Runs after every tool: masks secrets in the
|
|
422
|
+
* result and records the reason the invocation ended up here.
|
|
423
|
+
*/
|
|
424
|
+
function postExecute(_ctx, tool, result) {
|
|
425
|
+
if (result.result !== undefined) {
|
|
426
|
+
result.result = maskSecretsDeep(result.result);
|
|
427
|
+
}
|
|
428
|
+
if (typeof result.error === 'string') {
|
|
429
|
+
result.error = (0, security_1.maskSecrets)(result.error);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ok: true,
|
|
433
|
+
reason: `tool ${tool} completed; secrets masked`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
146
437
|
// executors
|
|
147
438
|
// ---------------------------------------------------------------------------
|
|
148
439
|
const listDir = async (ctx, args) => {
|
|
@@ -201,7 +492,11 @@ const listDir = async (ctx, args) => {
|
|
|
201
492
|
const readFile = async (ctx, args, signal) => {
|
|
202
493
|
abortIfRequested(signal);
|
|
203
494
|
const targetStr = requireString(args, 'path');
|
|
204
|
-
const
|
|
495
|
+
const offset = optionalInt(args, 'offset', 0, { min: 0 });
|
|
496
|
+
// `length` takes precedence over the legacy `maxBytes`; if neither is given,
|
|
497
|
+
// fall back to the configured output cap.
|
|
498
|
+
const hasLength = args.length !== undefined && args.length !== null;
|
|
499
|
+
const length = optionalInt(args, hasLength ? 'length' : 'maxBytes', ctx.maxBytes, { min: 1, max: MAX_WRITE_BYTES });
|
|
205
500
|
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
206
501
|
const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot);
|
|
207
502
|
assertNotSensitive(target);
|
|
@@ -216,9 +511,12 @@ const readFile = async (ctx, args, signal) => {
|
|
|
216
511
|
throw new ToolError('IS_DIR', `Path is a directory, not a file: ${targetStr}`);
|
|
217
512
|
}
|
|
218
513
|
const totalBytes = stat.size;
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
514
|
+
const available = Math.max(0, totalBytes - offset);
|
|
515
|
+
// Return exactly the requested window (capped at what is available) and
|
|
516
|
+
// report `truncated` when more bytes remain beyond that window.
|
|
517
|
+
const readLen = Math.min(length, available);
|
|
518
|
+
const buf = readUpTo(target, readLen, offset);
|
|
519
|
+
const truncated = available > length;
|
|
222
520
|
const bytesRead = buf.length;
|
|
223
521
|
const utf8 = isUtf8(buf);
|
|
224
522
|
const content = utf8 ? buf.toString('utf8') : buf.toString('hex');
|
|
@@ -228,6 +526,7 @@ const readFile = async (ctx, args, signal) => {
|
|
|
228
526
|
path: rel(realRoot, target),
|
|
229
527
|
bytesRead,
|
|
230
528
|
totalBytes,
|
|
529
|
+
offset,
|
|
231
530
|
truncated,
|
|
232
531
|
encoding: utf8 ? 'utf8' : 'hex',
|
|
233
532
|
content,
|
|
@@ -296,7 +595,7 @@ const runCommandTool = async (ctx, args, signal) => {
|
|
|
296
595
|
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
297
596
|
const cwd = (0, path_utils_1.safeResolve)(ctx.root, cwdArg, realRoot, { allowEqual: true });
|
|
298
597
|
const assessment = (0, security_1.isDangerCommand)(cmd);
|
|
299
|
-
if (assessment.danger) {
|
|
598
|
+
if (assessment.danger && !permitsDangerCommand(ctx.permissionPreset, assessment.severity)) {
|
|
300
599
|
return {
|
|
301
600
|
ok: false,
|
|
302
601
|
error: `Blocked dangerous command (${assessment.severity}): ${assessment.reason}`,
|
|
@@ -368,7 +667,7 @@ const git = async (ctx, args, signal) => {
|
|
|
368
667
|
throw new ToolError('INVALID_ARG', '"git" requires either "args" (array) or "command" (string).');
|
|
369
668
|
}
|
|
370
669
|
const assessment = (0, security_1.isDangerCommand)(cmdStr);
|
|
371
|
-
if (assessment.danger) {
|
|
670
|
+
if (assessment.danger && !permitsDangerCommand(ctx.permissionPreset, assessment.severity)) {
|
|
372
671
|
return { ok: false, error: `Blocked dangerous git command (${assessment.severity}): ${assessment.reason}` };
|
|
373
672
|
}
|
|
374
673
|
if (ctx.mode === 'supervise') {
|
|
@@ -453,15 +752,635 @@ const generate = async (ctx, args, signal) => {
|
|
|
453
752
|
result: { template, name, root: rel(realRoot, targetDir), files: written },
|
|
454
753
|
};
|
|
455
754
|
};
|
|
755
|
+
// --- search (grep) ---------------------------------------------------------
|
|
756
|
+
const search = async (ctx, args, signal) => {
|
|
757
|
+
abortIfRequested(signal);
|
|
758
|
+
const patternStr = requireString(args, 'pattern');
|
|
759
|
+
const targetStr = optionalString(args, 'path', '.') ?? '.';
|
|
760
|
+
const maxResults = optionalInt(args, 'maxResults', 100, { min: 1, max: 5000 });
|
|
761
|
+
const maxFileBytes = optionalInt(args, 'maxBytes', 256 * 1024, { min: 1, max: MAX_WRITE_BYTES });
|
|
762
|
+
const caseSensitive = optionalBool(args, 'caseSensitive', false);
|
|
763
|
+
const recursive = optionalBool(args, 'recursive', true);
|
|
764
|
+
let re;
|
|
765
|
+
try {
|
|
766
|
+
re = new RegExp(patternStr, caseSensitive ? 'gm' : 'gim');
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
throw new ToolError('INVALID_REGEX', `Invalid search pattern: ${patternStr}`);
|
|
770
|
+
}
|
|
771
|
+
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
772
|
+
const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot, { allowEqual: true });
|
|
773
|
+
assertNotSensitive(target);
|
|
774
|
+
let st;
|
|
775
|
+
try {
|
|
776
|
+
st = fs.statSync(target);
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
throw new ToolError('NOT_FOUND', `Path does not exist: ${targetStr}`);
|
|
780
|
+
}
|
|
781
|
+
const matches = [];
|
|
782
|
+
let count = 0;
|
|
783
|
+
const scanFile = (abs) => {
|
|
784
|
+
let mbuf;
|
|
785
|
+
try {
|
|
786
|
+
const fst = fs.statSync(abs);
|
|
787
|
+
if (fst.size > maxFileBytes)
|
|
788
|
+
return;
|
|
789
|
+
mbuf = fs.readFileSync(abs);
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (isBinary(mbuf))
|
|
795
|
+
return;
|
|
796
|
+
const lines = mbuf.toString('utf8').split('\n');
|
|
797
|
+
for (let i = 0; i < lines.length; i++) {
|
|
798
|
+
const lineText = lines[i];
|
|
799
|
+
re.lastIndex = 0;
|
|
800
|
+
// While loop with a zero-length-match guard to avoid infinite loops.
|
|
801
|
+
for (;;) {
|
|
802
|
+
const m = re.exec(lineText);
|
|
803
|
+
if (!m)
|
|
804
|
+
break;
|
|
805
|
+
count += 1;
|
|
806
|
+
if (matches.length < maxResults) {
|
|
807
|
+
matches.push({ path: rel(realRoot, abs), line: i + 1, text: lineText });
|
|
808
|
+
}
|
|
809
|
+
if (m[0] === '')
|
|
810
|
+
re.lastIndex = (m.index ?? 0) + 1;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
if (st.isFile()) {
|
|
815
|
+
scanFile(target);
|
|
816
|
+
}
|
|
817
|
+
else if (st.isDirectory()) {
|
|
818
|
+
if (recursive) {
|
|
819
|
+
const entries = walkTree(target, { maxEntries: Math.max(maxResults * 8, 500), includeHidden: false });
|
|
820
|
+
for (const e of entries) {
|
|
821
|
+
if (e.type !== 'file')
|
|
822
|
+
continue;
|
|
823
|
+
scanFile(e.abs);
|
|
824
|
+
if (matches.length >= maxResults && count > maxResults)
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
let names = [];
|
|
830
|
+
try {
|
|
831
|
+
names = fs.readdirSync(target).sort();
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
/* ignore */
|
|
835
|
+
}
|
|
836
|
+
for (const name of names) {
|
|
837
|
+
if (name.startsWith('.'))
|
|
838
|
+
continue;
|
|
839
|
+
const full = path.join(target, name);
|
|
840
|
+
let s;
|
|
841
|
+
try {
|
|
842
|
+
s = fs.lstatSync(full);
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (s.isSymbolicLink() || !s.isFile())
|
|
848
|
+
continue;
|
|
849
|
+
scanFile(full);
|
|
850
|
+
if (matches.length >= maxResults && count > maxResults)
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
throw new ToolError('NOT_A_FILE_OR_DIR', `Path is not a file or directory: ${targetStr}`);
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
ok: true,
|
|
860
|
+
result: {
|
|
861
|
+
pattern: patternStr,
|
|
862
|
+
path: rel(realRoot, target),
|
|
863
|
+
matches,
|
|
864
|
+
count,
|
|
865
|
+
truncated: count > matches.length,
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
};
|
|
869
|
+
// --- glob ------------------------------------------------------------------
|
|
870
|
+
const glob = async (ctx, args, signal) => {
|
|
871
|
+
abortIfRequested(signal);
|
|
872
|
+
const pattern = requireString(args, 'pattern');
|
|
873
|
+
const baseStr = optionalString(args, 'path', '.') ?? '.';
|
|
874
|
+
const maxResults = optionalInt(args, 'maxResults', 1000, { min: 1, max: 5000 });
|
|
875
|
+
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
876
|
+
const base = (0, path_utils_1.safeResolve)(ctx.root, baseStr, realRoot, { allowEqual: true });
|
|
877
|
+
assertNotSensitive(base);
|
|
878
|
+
let st;
|
|
879
|
+
try {
|
|
880
|
+
st = fs.statSync(base);
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
throw new ToolError('NOT_FOUND', `Path does not exist: ${baseStr}`);
|
|
884
|
+
}
|
|
885
|
+
if (!st.isDirectory()) {
|
|
886
|
+
throw new ToolError('NOT_A_DIR', `Path is not a directory: ${baseStr}`);
|
|
887
|
+
}
|
|
888
|
+
const re = globToRegex(pattern);
|
|
889
|
+
const entries = walkTree(base, { maxEntries: Math.max(maxResults * 4, 500), includeHidden: false });
|
|
890
|
+
const matches = [];
|
|
891
|
+
for (const e of entries) {
|
|
892
|
+
if (e.type && re.test(e.rel)) {
|
|
893
|
+
matches.push({ path: rel(realRoot, e.abs), type: e.type });
|
|
894
|
+
if (matches.length >= maxResults)
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
ok: true,
|
|
900
|
+
result: {
|
|
901
|
+
pattern,
|
|
902
|
+
path: rel(realRoot, base),
|
|
903
|
+
matches,
|
|
904
|
+
count: matches.length,
|
|
905
|
+
truncated: matches.length >= maxResults,
|
|
906
|
+
},
|
|
907
|
+
};
|
|
908
|
+
};
|
|
909
|
+
const tree = async (ctx, args, signal) => {
|
|
910
|
+
abortIfRequested(signal);
|
|
911
|
+
const targetStr = optionalString(args, 'path', '.') ?? '.';
|
|
912
|
+
const depth = optionalInt(args, 'depth', 2, { min: 0, max: 12 });
|
|
913
|
+
const maxEntries = optionalInt(args, 'maxEntries', 500, { min: 1, max: 5000 });
|
|
914
|
+
const includeHidden = optionalBool(args, 'showHidden', false);
|
|
915
|
+
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
916
|
+
const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot, { allowEqual: true });
|
|
917
|
+
assertNotSensitive(target);
|
|
918
|
+
let st;
|
|
919
|
+
try {
|
|
920
|
+
st = fs.statSync(target);
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
throw new ToolError('NOT_FOUND', `Path does not exist: ${targetStr}`);
|
|
924
|
+
}
|
|
925
|
+
if (!st.isDirectory()) {
|
|
926
|
+
throw new ToolError('NOT_A_DIR', `Path is not a directory: ${targetStr}`);
|
|
927
|
+
}
|
|
928
|
+
let count = 0;
|
|
929
|
+
let truncated = false;
|
|
930
|
+
const build = (absDir, cur) => {
|
|
931
|
+
if (cur > depth)
|
|
932
|
+
return [];
|
|
933
|
+
let names = [];
|
|
934
|
+
try {
|
|
935
|
+
names = fs.readdirSync(absDir).sort();
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
return [];
|
|
939
|
+
}
|
|
940
|
+
const nodes = [];
|
|
941
|
+
for (const name of names) {
|
|
942
|
+
if (!includeHidden && name.startsWith('.'))
|
|
943
|
+
continue;
|
|
944
|
+
const full = path.join(absDir, name);
|
|
945
|
+
let cst;
|
|
946
|
+
try {
|
|
947
|
+
cst = fs.lstatSync(full);
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (cst.isSymbolicLink())
|
|
953
|
+
continue;
|
|
954
|
+
const type = cst.isDirectory() ? 'dir' : cst.isFile() ? 'file' : '';
|
|
955
|
+
if (!type)
|
|
956
|
+
continue;
|
|
957
|
+
count += 1;
|
|
958
|
+
const node = {
|
|
959
|
+
name,
|
|
960
|
+
path: rel(realRoot, full),
|
|
961
|
+
type,
|
|
962
|
+
...(cst.isFile() ? { size: cst.size } : {}),
|
|
963
|
+
};
|
|
964
|
+
if (type === 'dir' && cur + 1 <= depth) {
|
|
965
|
+
const children = build(full, cur + 1);
|
|
966
|
+
if (children.length)
|
|
967
|
+
node.children = children;
|
|
968
|
+
}
|
|
969
|
+
nodes.push(node);
|
|
970
|
+
if (count >= maxEntries) {
|
|
971
|
+
truncated = true;
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return nodes;
|
|
976
|
+
};
|
|
977
|
+
const treeNodes = build(target, 0);
|
|
978
|
+
const lines = [rel(realRoot, target) || '.'];
|
|
979
|
+
const render = (nodes, prefix) => {
|
|
980
|
+
nodes.forEach((node, idx) => {
|
|
981
|
+
const last = idx === nodes.length - 1;
|
|
982
|
+
lines.push(`${prefix}${last ? '└── ' : '├── '}${node.name}${node.type === 'dir' ? '/' : ''}`);
|
|
983
|
+
if (node.children && node.children.length) {
|
|
984
|
+
render(node.children, `${prefix}${last ? ' ' : '│ '}`);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
};
|
|
988
|
+
render(treeNodes, '');
|
|
989
|
+
return {
|
|
990
|
+
ok: true,
|
|
991
|
+
result: {
|
|
992
|
+
path: rel(realRoot, target),
|
|
993
|
+
depth,
|
|
994
|
+
truncated,
|
|
995
|
+
count,
|
|
996
|
+
tree: treeNodes,
|
|
997
|
+
text: lines.join('\n'),
|
|
998
|
+
},
|
|
999
|
+
};
|
|
1000
|
+
};
|
|
1001
|
+
// --- str_replace_editor ----------------------------------------------------
|
|
1002
|
+
function readLineRange(args) {
|
|
1003
|
+
const v = args.view_range;
|
|
1004
|
+
if (v === undefined || v === null)
|
|
1005
|
+
return null;
|
|
1006
|
+
if (!Array.isArray(v) || v.length !== 2) {
|
|
1007
|
+
throw new ToolError('INVALID_ARG', '"view_range" must be a [start, end] array.');
|
|
1008
|
+
}
|
|
1009
|
+
const start = Number(v[0]);
|
|
1010
|
+
const end = Number(v[1]);
|
|
1011
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
|
|
1012
|
+
throw new ToolError('INVALID_ARG', '"view_range" must be [start, end] with 1 <= start <= end.');
|
|
1013
|
+
}
|
|
1014
|
+
return [start, end];
|
|
1015
|
+
}
|
|
1016
|
+
const strReplaceEditor = async (ctx, args, signal) => {
|
|
1017
|
+
abortIfRequested(signal);
|
|
1018
|
+
const command = requireString(args, 'command').toLowerCase();
|
|
1019
|
+
const pathStr = requireString(args, 'path');
|
|
1020
|
+
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
1021
|
+
const target = (0, path_utils_1.safeResolve)(ctx.root, pathStr, realRoot);
|
|
1022
|
+
assertNotSensitive(target);
|
|
1023
|
+
const assertNotRootTarget = () => {
|
|
1024
|
+
if (path.relative(realRoot, target) === '') {
|
|
1025
|
+
throw new ToolError('ROOT_PATH', 'The authorisation root itself cannot be edited.');
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
const ensureFile = () => {
|
|
1029
|
+
let st;
|
|
1030
|
+
try {
|
|
1031
|
+
st = fs.statSync(target);
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
throw new ToolError('NOT_FOUND', `Path does not exist: ${pathStr}`);
|
|
1035
|
+
}
|
|
1036
|
+
if (st.isDirectory()) {
|
|
1037
|
+
throw new ToolError('IS_DIR', `Path is a directory, not a file: ${pathStr}`);
|
|
1038
|
+
}
|
|
1039
|
+
const buf = fs.readFileSync(target);
|
|
1040
|
+
if (!isUtf8(buf)) {
|
|
1041
|
+
throw new ToolError('BINARY_FILE', 'str_replace_editor only supports UTF-8 text files.');
|
|
1042
|
+
}
|
|
1043
|
+
return buf.toString('utf8');
|
|
1044
|
+
};
|
|
1045
|
+
switch (command) {
|
|
1046
|
+
case 'create': {
|
|
1047
|
+
assertNotRootTarget();
|
|
1048
|
+
const fileText = requireString(args, 'file_text');
|
|
1049
|
+
const allowOverwrite = optionalBool(args, 'allowOverwrite', false);
|
|
1050
|
+
if (fs.existsSync(target) && !allowOverwrite) {
|
|
1051
|
+
throw new ToolError('EXISTS', `File already exists: ${pathStr} (pass allowOverwrite:true to replace it).`);
|
|
1052
|
+
}
|
|
1053
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1054
|
+
fs.writeFileSync(target, fileText, 'utf8');
|
|
1055
|
+
return { ok: true, result: { path: rel(realRoot, target), bytesWritten: Buffer.byteLength(fileText) } };
|
|
1056
|
+
}
|
|
1057
|
+
case 'view': {
|
|
1058
|
+
const text = ensureFile();
|
|
1059
|
+
const allLines = text === '' ? [] : text.split('\n');
|
|
1060
|
+
const range = readLineRange(args);
|
|
1061
|
+
const selected = range ? allLines.slice(range[0] - 1, range[1]) : allLines;
|
|
1062
|
+
const joined = selected.join('\n');
|
|
1063
|
+
const content = joined.length > ctx.maxBytes ? joined.slice(0, ctx.maxBytes) : joined;
|
|
1064
|
+
return {
|
|
1065
|
+
ok: true,
|
|
1066
|
+
result: {
|
|
1067
|
+
path: rel(realRoot, target),
|
|
1068
|
+
totalLines: allLines.length,
|
|
1069
|
+
lineStart: range ? range[0] : 1,
|
|
1070
|
+
lineCount: selected.length,
|
|
1071
|
+
truncated: joined.length > content.length,
|
|
1072
|
+
content,
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
case 'str_replace': {
|
|
1077
|
+
assertNotRootTarget();
|
|
1078
|
+
const text = ensureFile();
|
|
1079
|
+
const oldStr = requireString(args, 'old_str');
|
|
1080
|
+
const newStr = typeof args.new_str === 'string' ? args.new_str : '';
|
|
1081
|
+
const replaceAll = optionalBool(args, 'replace_all', false);
|
|
1082
|
+
const count = text.split(oldStr).length - 1;
|
|
1083
|
+
if (count === 0) {
|
|
1084
|
+
throw new ToolError('NO_MATCH', 'old_str was not found in the file.');
|
|
1085
|
+
}
|
|
1086
|
+
if (!replaceAll && count !== 1) {
|
|
1087
|
+
throw new ToolError('MULTIPLE_MATCHES', `old_str matched ${count} times; pass replace_all:true to replace all of them.`);
|
|
1088
|
+
}
|
|
1089
|
+
const updated = replaceAll ? text.split(oldStr).join(newStr) : text.replace(oldStr, newStr);
|
|
1090
|
+
fs.writeFileSync(target, updated, 'utf8');
|
|
1091
|
+
return { ok: true, result: { path: rel(realRoot, target), replacements: count } };
|
|
1092
|
+
}
|
|
1093
|
+
case 'insert': {
|
|
1094
|
+
assertNotRootTarget();
|
|
1095
|
+
const text = ensureFile();
|
|
1096
|
+
const newStr = requireString(args, 'new_str');
|
|
1097
|
+
const insertLine = optionalInt(args, 'insert_line', 0, { min: 0 });
|
|
1098
|
+
const lines = text === '' ? [] : text.split('\n');
|
|
1099
|
+
// insert_line is 1-based; 0 or out-of-range appends.
|
|
1100
|
+
const pos = insertLine >= 1 && insertLine <= lines.length + 1 ? insertLine - 1 : lines.length;
|
|
1101
|
+
lines.splice(pos, 0, newStr);
|
|
1102
|
+
fs.writeFileSync(target, lines.join('\n'), 'utf8');
|
|
1103
|
+
return { ok: true, result: { path: rel(realRoot, target), line: pos + 1, inserted: newStr } };
|
|
1104
|
+
}
|
|
1105
|
+
case 'delete': {
|
|
1106
|
+
assertNotRootTarget();
|
|
1107
|
+
const text = ensureFile();
|
|
1108
|
+
const range = readLineRange(args);
|
|
1109
|
+
if (!range) {
|
|
1110
|
+
throw new ToolError('INVALID_ARG', '"delete" requires a "view_range" [start, end].');
|
|
1111
|
+
}
|
|
1112
|
+
const lines = text === '' ? [] : text.split('\n');
|
|
1113
|
+
const [start, end] = range;
|
|
1114
|
+
if (start > lines.length) {
|
|
1115
|
+
throw new ToolError('RANGE_OUT_OF_BOUNDS', `Line ${start} is out of range (file has ${lines.length} lines).`);
|
|
1116
|
+
}
|
|
1117
|
+
const removed = lines.slice(start - 1, Math.min(end, lines.length));
|
|
1118
|
+
lines.splice(start - 1, Math.min(end, lines.length) - (start - 1));
|
|
1119
|
+
fs.writeFileSync(target, lines.join('\n'), 'utf8');
|
|
1120
|
+
return { ok: true, result: { path: rel(realRoot, target), lineStart: start, lineEnd: Math.min(end, start + removed.length - 1), removed: removed.length } };
|
|
1121
|
+
}
|
|
1122
|
+
default:
|
|
1123
|
+
throw new ToolError('INVALID_ARG', `Unknown str_replace_editor command "${command}". Expected: create | view | str_replace | insert | delete.`);
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
// --- mv / cp / delete ------------------------------------------------------
|
|
1127
|
+
/** Recursively copy a file or directory within the authorisation root. Never
|
|
1128
|
+
* follows symlinks so a copy cannot escape the root. */
|
|
1129
|
+
function copyWithinRoot(src, dst) {
|
|
1130
|
+
const sst = fs.statSync(src);
|
|
1131
|
+
if (sst.isDirectory()) {
|
|
1132
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
1133
|
+
for (const name of fs.readdirSync(src)) {
|
|
1134
|
+
const childSrc = path.join(src, name);
|
|
1135
|
+
let cst;
|
|
1136
|
+
try {
|
|
1137
|
+
cst = fs.lstatSync(childSrc);
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
if (cst.isSymbolicLink())
|
|
1143
|
+
continue;
|
|
1144
|
+
copyWithinRoot(childSrc, path.join(dst, name));
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
fs.copyFileSync(src, dst);
|
|
1149
|
+
try {
|
|
1150
|
+
fs.chmodSync(dst, sst.mode & 0o777);
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
/* best-effort */
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
const mv = async (ctx, args, signal) => {
|
|
1158
|
+
abortIfRequested(signal);
|
|
1159
|
+
const srcArg = requireString(args, 'source');
|
|
1160
|
+
const dstArg = requireString(args, 'destination');
|
|
1161
|
+
const overwrite = optionalBool(args, 'overwrite', false);
|
|
1162
|
+
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
1163
|
+
const src = (0, path_utils_1.safeResolve)(ctx.root, srcArg, realRoot);
|
|
1164
|
+
const dst = (0, path_utils_1.safeResolve)(ctx.root, dstArg, realRoot);
|
|
1165
|
+
assertNotSensitive(src);
|
|
1166
|
+
assertNotSensitive(dst);
|
|
1167
|
+
if (path.relative(realRoot, dst) === '') {
|
|
1168
|
+
throw new ToolError('ROOT_PATH', 'The authorisation root itself cannot be a move destination.');
|
|
1169
|
+
}
|
|
1170
|
+
let ss;
|
|
1171
|
+
try {
|
|
1172
|
+
ss = fs.statSync(src);
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
throw new ToolError('NOT_FOUND', `Source does not exist: ${srcArg}`);
|
|
1176
|
+
}
|
|
1177
|
+
if (fs.existsSync(dst)) {
|
|
1178
|
+
if (!overwrite) {
|
|
1179
|
+
return { ok: false, error: `Destination already exists: ${dstArg} (pass overwrite:true to replace).` };
|
|
1180
|
+
}
|
|
1181
|
+
if (!permitsDangerCommand(ctx.permissionPreset, 'danger')) {
|
|
1182
|
+
return { ok: false, needsApproval: true, error: `Overwrites existing path ${dstArg}; requires approval.` };
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1186
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
1187
|
+
try {
|
|
1188
|
+
fs.renameSync(src, dst);
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
const code = err.code;
|
|
1192
|
+
if (code === 'EXDEV' || code === 'EPERM') {
|
|
1193
|
+
copyWithinRoot(src, dst);
|
|
1194
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
throw err;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
catch (err) {
|
|
1202
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1203
|
+
return { ok: false, error: `move failed: ${msg}` };
|
|
1204
|
+
}
|
|
1205
|
+
return { ok: true, result: { from: rel(realRoot, src), to: rel(realRoot, dst) } };
|
|
1206
|
+
};
|
|
1207
|
+
const cp = async (ctx, args, signal) => {
|
|
1208
|
+
abortIfRequested(signal);
|
|
1209
|
+
const srcArg = requireString(args, 'source');
|
|
1210
|
+
const dstArg = requireString(args, 'destination');
|
|
1211
|
+
const overwrite = optionalBool(args, 'overwrite', false);
|
|
1212
|
+
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
1213
|
+
const src = (0, path_utils_1.safeResolve)(ctx.root, srcArg, realRoot);
|
|
1214
|
+
const dst = (0, path_utils_1.safeResolve)(ctx.root, dstArg, realRoot);
|
|
1215
|
+
assertNotSensitive(src);
|
|
1216
|
+
assertNotSensitive(dst);
|
|
1217
|
+
if (path.relative(realRoot, dst) === '') {
|
|
1218
|
+
throw new ToolError('ROOT_PATH', 'The authorisation root itself cannot be a copy destination.');
|
|
1219
|
+
}
|
|
1220
|
+
let ss;
|
|
1221
|
+
try {
|
|
1222
|
+
ss = fs.statSync(src);
|
|
1223
|
+
}
|
|
1224
|
+
catch {
|
|
1225
|
+
throw new ToolError('NOT_FOUND', `Source does not exist: ${srcArg}`);
|
|
1226
|
+
}
|
|
1227
|
+
if (fs.existsSync(dst)) {
|
|
1228
|
+
if (!overwrite) {
|
|
1229
|
+
return { ok: false, error: `Destination already exists: ${dstArg} (pass overwrite:true to replace).` };
|
|
1230
|
+
}
|
|
1231
|
+
if (!permitsDangerCommand(ctx.permissionPreset, 'danger')) {
|
|
1232
|
+
return { ok: false, needsApproval: true, error: `Overwrites existing path ${dstArg}; requires approval.` };
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
try {
|
|
1236
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
1237
|
+
if (path.relative(src, dst) === '') {
|
|
1238
|
+
throw new ToolError('INVALID_ARG', 'Source and destination cannot be the same.');
|
|
1239
|
+
}
|
|
1240
|
+
copyWithinRoot(src, dst);
|
|
1241
|
+
}
|
|
1242
|
+
catch (err) {
|
|
1243
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1244
|
+
return { ok: false, error: `copy failed: ${msg}` };
|
|
1245
|
+
}
|
|
1246
|
+
return { ok: true, result: { from: rel(realRoot, src), to: rel(realRoot, dst) } };
|
|
1247
|
+
};
|
|
1248
|
+
const deleteFile = async (ctx, args, signal) => {
|
|
1249
|
+
abortIfRequested(signal);
|
|
1250
|
+
const pathStr = requireString(args, 'path');
|
|
1251
|
+
const recursive = optionalBool(args, 'recursive', false);
|
|
1252
|
+
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
1253
|
+
const target = (0, path_utils_1.safeResolve)(ctx.root, pathStr, realRoot);
|
|
1254
|
+
assertNotSensitive(target);
|
|
1255
|
+
if (path.relative(realRoot, target) === '') {
|
|
1256
|
+
throw new ToolError('ROOT_PATH', 'Refusing to delete the authorisation root.');
|
|
1257
|
+
}
|
|
1258
|
+
let st;
|
|
1259
|
+
try {
|
|
1260
|
+
st = fs.statSync(target);
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
throw new ToolError('NOT_FOUND', `Path does not exist: ${pathStr}`);
|
|
1264
|
+
}
|
|
1265
|
+
// Delete is destructive: always require human confirmation unless the most
|
|
1266
|
+
// permissive preset is active.
|
|
1267
|
+
if (!permitsDangerCommand(ctx.permissionPreset, 'danger')) {
|
|
1268
|
+
return { ok: false, needsApproval: true, error: `Deleting ${pathStr} requires approval.` };
|
|
1269
|
+
}
|
|
1270
|
+
try {
|
|
1271
|
+
if (st.isDirectory() && !recursive) {
|
|
1272
|
+
if (fs.readdirSync(target).length !== 0) {
|
|
1273
|
+
throw new ToolError('DIR_NOT_EMPTY', `Directory is not empty: ${pathStr} (pass recursive:true).`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
fs.rmSync(target, { recursive, force: false });
|
|
1277
|
+
}
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
if (err instanceof ToolError)
|
|
1280
|
+
throw err;
|
|
1281
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1282
|
+
return { ok: false, error: `delete failed: ${msg}` };
|
|
1283
|
+
}
|
|
1284
|
+
return { ok: true, result: { path: rel(realRoot, target) } };
|
|
1285
|
+
};
|
|
1286
|
+
// --- json pretty-print -----------------------------------------------------
|
|
1287
|
+
function sortKeysDeep(value) {
|
|
1288
|
+
if (Array.isArray(value))
|
|
1289
|
+
return value.map(sortKeysDeep);
|
|
1290
|
+
if (value && typeof value === 'object') {
|
|
1291
|
+
const out = {};
|
|
1292
|
+
for (const k of Object.keys(value).sort()) {
|
|
1293
|
+
out[k] = sortKeysDeep(value[k]);
|
|
1294
|
+
}
|
|
1295
|
+
return out;
|
|
1296
|
+
}
|
|
1297
|
+
return value;
|
|
1298
|
+
}
|
|
1299
|
+
const jsonTool = async (ctx, args, signal) => {
|
|
1300
|
+
abortIfRequested(signal);
|
|
1301
|
+
const pathStr = optionalString(args, 'path');
|
|
1302
|
+
const indent = optionalInt(args, 'indent', 2, { min: 0, max: 16 });
|
|
1303
|
+
const sortKeys = optionalBool(args, 'sortKeys', false);
|
|
1304
|
+
let raw;
|
|
1305
|
+
let outPath;
|
|
1306
|
+
if (pathStr) {
|
|
1307
|
+
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
1308
|
+
const target = (0, path_utils_1.safeResolve)(ctx.root, pathStr, realRoot);
|
|
1309
|
+
assertNotSensitive(target);
|
|
1310
|
+
let st;
|
|
1311
|
+
try {
|
|
1312
|
+
st = fs.statSync(target);
|
|
1313
|
+
}
|
|
1314
|
+
catch {
|
|
1315
|
+
throw new ToolError('NOT_FOUND', `Path does not exist: ${pathStr}`);
|
|
1316
|
+
}
|
|
1317
|
+
if (st.size > MAX_WRITE_BYTES) {
|
|
1318
|
+
throw new ToolError('TOO_LARGE', `File exceeds ${MAX_WRITE_BYTES} bytes.`);
|
|
1319
|
+
}
|
|
1320
|
+
const buf = fs.readFileSync(target);
|
|
1321
|
+
if (!isUtf8(buf)) {
|
|
1322
|
+
throw new ToolError('BINARY_FILE', 'json only supports UTF-8 text files.');
|
|
1323
|
+
}
|
|
1324
|
+
raw = buf.toString('utf8');
|
|
1325
|
+
outPath = rel(realRoot, target);
|
|
1326
|
+
}
|
|
1327
|
+
else {
|
|
1328
|
+
const v = args.value;
|
|
1329
|
+
if (typeof v === 'string') {
|
|
1330
|
+
raw = v;
|
|
1331
|
+
}
|
|
1332
|
+
else if (v !== undefined && v !== null) {
|
|
1333
|
+
raw = JSON.stringify(v);
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
throw new ToolError('INVALID_ARG', '"json" requires either "value" (string) or "path".');
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
let parsed;
|
|
1340
|
+
try {
|
|
1341
|
+
parsed = JSON.parse(raw);
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1345
|
+
throw new ToolError('INVALID_JSON', `Invalid JSON: ${msg}`);
|
|
1346
|
+
}
|
|
1347
|
+
const pretty = JSON.stringify(sortKeys ? sortKeysDeep(parsed) : parsed, null, indent);
|
|
1348
|
+
return { ok: true, result: { json: pretty.length > ctx.maxBytes ? `${pretty.slice(0, ctx.maxBytes)}\n…(truncated)` : pretty, ...(outPath ? { path: outPath } : {}) } };
|
|
1349
|
+
};
|
|
1350
|
+
// --- skills ----------------------------------------------------------------
|
|
1351
|
+
/** Load a skill (`.dsh/skills/<name>.md` under the workspace root). No-op when
|
|
1352
|
+
* no skills directory or no matching skill exists. */
|
|
1353
|
+
const skillTool = async (ctx, args, signal) => {
|
|
1354
|
+
abortIfRequested(signal);
|
|
1355
|
+
const name = optionalString(args, 'name', '') ?? '';
|
|
1356
|
+
if (name === '') {
|
|
1357
|
+
const available = (0, skills_1.listSkills)(ctx.root);
|
|
1358
|
+
const note = available.length === 0
|
|
1359
|
+
? 'No skills are configured.'
|
|
1360
|
+
: `Name required; available skills: ${available.join(', ')}.`;
|
|
1361
|
+
return { ok: true, result: { loaded: false, available, note } };
|
|
1362
|
+
}
|
|
1363
|
+
const record = (0, skills_1.loadSkillRecord)(ctx.root, name);
|
|
1364
|
+
return { ok: true, result: record };
|
|
1365
|
+
};
|
|
456
1366
|
const TOOLS = {
|
|
457
1367
|
list_dir: listDir,
|
|
458
1368
|
read_file: readFile,
|
|
459
1369
|
write_file: writeFile,
|
|
460
1370
|
edit_file: editFile,
|
|
1371
|
+
str_replace_editor: strReplaceEditor,
|
|
461
1372
|
run_command: runCommandTool,
|
|
462
1373
|
web_search: webSearch,
|
|
463
1374
|
git,
|
|
464
1375
|
generate,
|
|
1376
|
+
search,
|
|
1377
|
+
glob,
|
|
1378
|
+
tree,
|
|
1379
|
+
mv,
|
|
1380
|
+
cp,
|
|
1381
|
+
delete_file: deleteFile,
|
|
1382
|
+
json: jsonTool,
|
|
1383
|
+
skill: skillTool,
|
|
465
1384
|
};
|
|
466
1385
|
/** Build a tool registry bound to a single execution context. */
|
|
467
1386
|
function createToolRegistry(ctx) {
|
|
@@ -479,9 +1398,39 @@ function createToolRegistry(ctx) {
|
|
|
479
1398
|
return { ok: false, error: `Unknown tool: ${name}`, durationMs: 0 };
|
|
480
1399
|
}
|
|
481
1400
|
try {
|
|
1401
|
+
// Guarded gate: pre-execution check (permission preset, danger
|
|
1402
|
+
// commands, canonicalise + sensitive-path).
|
|
1403
|
+
const pre = preExecute(ctx, name, args ?? {});
|
|
1404
|
+
if (!pre.ok) {
|
|
1405
|
+
const durationMs = Date.now() - started;
|
|
1406
|
+
return {
|
|
1407
|
+
ok: false,
|
|
1408
|
+
error: pre.error,
|
|
1409
|
+
durationMs,
|
|
1410
|
+
...(pre.needsApproval ? { needsApproval: true } : {}),
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
// A cancel message may arrive while pre-checking; honour it.
|
|
1414
|
+
abortIfRequested(signal);
|
|
482
1415
|
const outcome = await executor(ctx, args ?? {}, signal);
|
|
483
|
-
const
|
|
484
|
-
|
|
1416
|
+
const result = {
|
|
1417
|
+
ok: outcome.ok,
|
|
1418
|
+
result: outcome.result,
|
|
1419
|
+
error: outcome.error,
|
|
1420
|
+
durationMs: Date.now() - started,
|
|
1421
|
+
...(outcome.needsApproval ? { needsApproval: true } : {}),
|
|
1422
|
+
};
|
|
1423
|
+
// Guarded gate: post-execution check (mask secrets + record reason).
|
|
1424
|
+
const post = postExecute(ctx, name, result);
|
|
1425
|
+
if (!post.ok) {
|
|
1426
|
+
const durationMs = Date.now() - started;
|
|
1427
|
+
return {
|
|
1428
|
+
ok: false,
|
|
1429
|
+
error: post.error ?? 'post-execution check failed',
|
|
1430
|
+
durationMs,
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
return result;
|
|
485
1434
|
}
|
|
486
1435
|
catch (err) {
|
|
487
1436
|
const durationMs = Date.now() - started;
|