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/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, 0);
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 maxBytes = optionalInt(args, 'maxBytes', ctx.maxBytes, { min: 1, max: MAX_WRITE_BYTES });
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 readLen = Math.min(maxBytes + 1, totalBytes);
220
- const buf = readUpTo(target, readLen);
221
- const truncated = totalBytes > maxBytes;
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 durationMs = Date.now() - started;
484
- return { ok: outcome.ok, result: outcome.result, error: outcome.error, durationMs };
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;