@abelxiaoxing/cadence 1.3.0 → 1.4.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.
Files changed (45) hide show
  1. package/README.md +115 -10
  2. package/package.json +5 -3
  3. package/prompts/abel-design.md +71 -17
  4. package/prompts/abel-diagnose.md +10 -1
  5. package/prompts/abel-implement.md +118 -20
  6. package/prompts/abel-init.md +9 -2
  7. package/src/apply-transaction.ts +16 -14
  8. package/src/artifact-store.ts +17 -0
  9. package/src/candidate-patch.ts +2 -1
  10. package/src/canonical.ts +16 -0
  11. package/src/change-contract.ts +268 -0
  12. package/src/child-session.ts +4 -3
  13. package/src/contracts.ts +141 -33
  14. package/src/control-contracts.ts +51 -1
  15. package/src/delivery-compiler.ts +148 -31
  16. package/src/design-control.ts +69 -11
  17. package/src/design-journal.ts +216 -72
  18. package/src/durable-workflow.ts +4385 -0
  19. package/src/implement-graph.ts +11 -16
  20. package/src/index.ts +380 -981
  21. package/src/isolation-backend.ts +84 -23
  22. package/src/package-delivery.ts +486 -0
  23. package/src/package-verification.ts +509 -0
  24. package/src/route-policy.ts +1 -11
  25. package/src/run-state.ts +4 -16
  26. package/src/run-store.ts +5 -144
  27. package/src/scoped-grep-worker.mjs +23 -0
  28. package/src/scoped-tools.ts +312 -116
  29. package/src/sqlite-schema.ts +171 -0
  30. package/src/storage-schema.ts +352 -0
  31. package/src/subagent-activity.ts +14 -1
  32. package/src/submit-tool.ts +20 -2
  33. package/src/task-ledger.ts +14 -55
  34. package/src/verification-capability.ts +298 -102
  35. package/src/verification-environment.ts +213 -0
  36. package/src/verification-identity.ts +77 -0
  37. package/src/worker-broker.ts +97 -34
  38. package/src/workflow-engine.ts +28 -8285
  39. package/src/workflow-policy.ts +1427 -0
  40. package/src/workflow-state-machine.ts +4478 -0
  41. package/src/workspace-io-entry.mjs +58 -0
  42. package/src/workspace-io-worker.mjs +984 -0
  43. package/src/workspace-io.ts +84 -0
  44. package/src/workspace-store.ts +125 -19
  45. package/skills/abel-workflow/SKILL.md +0 -205
@@ -1,6 +1,7 @@
1
1
  // Scoped read-only filesystem tools: read, grep, find, ls. Repository-relative
2
2
  // paths only, no mutation, no process execution, bounded output and scan sizes.
3
3
  import {
4
+ constants,
4
5
  lstatSync,
5
6
  readdirSync,
6
7
  readFileSync,
@@ -8,7 +9,10 @@ import {
8
9
  type Stats,
9
10
  statSync,
10
11
  } from "node:fs";
12
+ import { lstat, open, opendir } from "node:fs/promises";
11
13
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
14
+ import { Worker } from "node:worker_threads";
15
+ import { observeSafePath } from "./safe-path.ts";
12
16
 
13
17
  export const TOOL_LIMITS = {
14
18
  maxReadBytes: 50 * 1024,
@@ -16,6 +20,10 @@ export const TOOL_LIMITS = {
16
20
  maxGrepPattern: 1024,
17
21
  maxGrepFiles: 2000,
18
22
  maxGrepMillis: 5000,
23
+ maxGrepFileBytes: 1024 * 1024,
24
+ maxGrepScanBytes: 16 * 1024 * 1024,
25
+ maxGrepMatches: 1000,
26
+ maxGrepResultBytes: 50 * 1024,
19
27
  maxEntries: 20000,
20
28
  } as const;
21
29
 
@@ -171,10 +179,14 @@ export class ScopedObservationCollector {
171
179
  export interface ScopedToolDef {
172
180
  name: string;
173
181
  description: string;
174
- execute(params: Record<string, unknown>): Promise<Record<string, unknown>>;
182
+ execute(
183
+ params: Record<string, unknown>,
184
+ signal?: AbortSignal,
185
+ ): Promise<Record<string, unknown>>;
175
186
  }
176
187
 
177
188
  interface Options {
189
+ cwd?: string;
178
190
  roots: string[];
179
191
  allowedPaths?: string[];
180
192
  observer?: (obs: Observation) => void;
@@ -190,6 +202,7 @@ function resolveScoped(
190
202
  input: unknown,
191
203
  allowedPaths?: string[],
192
204
  allowScopedAncestor = false,
205
+ workspaceRoot = commonAncestor(roots),
193
206
  ): PathResult {
194
207
  if (typeof input !== "string" || input.length === 0) {
195
208
  return { ok: false, error: "missing path" };
@@ -206,59 +219,56 @@ function resolveScoped(
206
219
  if (input.startsWith("./") || input.includes("//") || input.endsWith("/.")) {
207
220
  return { ok: false, error: "noncanonical path rejected" };
208
221
  }
209
- let insideRoot = false;
210
- for (const root of roots) {
211
- const abs = resolve(root, input);
212
- const inside =
213
- abs === root || abs.startsWith(root.endsWith("/") ? root : `${root}/`);
214
- if (!inside) continue;
215
- insideRoot = true;
216
- if (
217
- allowedPaths &&
218
- !allowedPaths.some(
219
- (allowed) =>
220
- isWithinPath(abs, allowed) ||
221
- (allowScopedAncestor && isWithinPath(allowed, abs)),
222
- )
223
- ) {
224
- continue;
225
- }
226
- // Hidden directories are rejected unless the root itself is the hidden scope.
227
- const hiddenRoot =
228
- basename(realpathSync(root)) === ".git" ||
229
- basename(realpathSync(root)) === "node_modules";
230
- for (const segment of input.split("/").slice(0, -1)) {
231
- if ((segment === ".git" || segment === "node_modules") && !hiddenRoot) {
232
- return { ok: false, error: "hidden path rejected" };
233
- }
234
- }
235
- // Reject symlinks on any existing component and verify realpath containment.
236
- let current = root;
237
- for (const segment of input.split("/")) {
238
- current = join(current, segment);
239
- let st: Stats | undefined;
240
- try {
241
- st = lstatSync(current);
242
- } catch {
243
- break; // nonexistent leaf is fine for absent markers
244
- }
245
- if (st.isSymbolicLink()) return { ok: false, error: "symlink rejected" };
246
- }
247
- if (existsSync(current)) {
248
- const real = realpathSync(current);
249
- const realRoot = realpathSync(root);
250
- if (real !== realRoot && !real.startsWith(`${realRoot}/`)) {
251
- return { ok: false, error: "path escapes the approved root" };
252
- }
222
+ if (/^[a-z]:/iu.test(input))
223
+ return { ok: false, error: "absolute path rejected" };
224
+ const abs = resolve(workspaceRoot, input);
225
+ const root = [...roots]
226
+ .sort((a, b) => b.length - a.length)
227
+ .find(
228
+ (candidate) =>
229
+ isWithinPath(abs, candidate) ||
230
+ (allowScopedAncestor && isWithinPath(candidate, abs)),
231
+ );
232
+ if (!root || !isWithinPath(abs, workspaceRoot)) {
233
+ return { ok: false, error: "path is outside every approved root" };
234
+ }
235
+ if (
236
+ !isAllowedContent(abs, allowedPaths) &&
237
+ !(allowScopedAncestor && isRelatedToAllowedPath(abs, allowedPaths))
238
+ ) {
239
+ return {
240
+ ok: false,
241
+ error: "path is outside the declared read/write scope",
242
+ };
243
+ }
244
+ // Check all components from the workspace, including the admitted root.
245
+ const hiddenRoot =
246
+ basename(root) === ".git" || basename(root) === "node_modules";
247
+ if (
248
+ !hiddenRoot &&
249
+ input
250
+ .split("/")
251
+ .some((segment) => segment === ".git" || segment === "node_modules")
252
+ ) {
253
+ return { ok: false, error: "hidden path rejected" };
254
+ }
255
+ let current = workspaceRoot;
256
+ for (const segment of [".", ...relative(workspaceRoot, abs).split(sep)]) {
257
+ current = join(current, segment);
258
+ try {
259
+ if (lstatSync(current).isSymbolicLink())
260
+ return { ok: false, error: "symlink rejected" };
261
+ } catch {
262
+ break;
253
263
  }
254
- return { ok: true, abs: abs, rel: relative(root, abs) || "." };
255
264
  }
256
- return {
257
- ok: false,
258
- error: insideRoot
259
- ? "path is outside the declared read/write scope"
260
- : "path is outside every approved root",
261
- };
265
+ if (existsSync(current)) {
266
+ const real = realpathSync(current);
267
+ const realRoot = realpathSync(workspaceRoot);
268
+ if (!isWithinPath(real, realRoot))
269
+ return { ok: false, error: "path escapes the approved root" };
270
+ }
271
+ return { ok: true, abs, rel: relativePath(workspaceRoot, abs) };
262
272
  }
263
273
 
264
274
  function existsSync(p: string): boolean {
@@ -282,38 +292,57 @@ function utf8Text(abs: string): string | null {
282
292
 
283
293
  export function createScopedTools(opts: Options): ScopedToolDef[] {
284
294
  const roots = opts.roots.map((r) => resolve(r));
295
+ if (roots.length === 0) throw new Error("approved roots are required");
296
+ const workspaceRoot = resolve(opts.cwd ?? commonAncestor(roots));
297
+ if (!roots.every((root) => isWithinPath(root, workspaceRoot)))
298
+ throw new Error("approved root outside workspace");
285
299
  const allowedPaths = opts.allowedPaths?.map((p) => resolve(p));
286
300
  const observe = opts.observer ?? (() => {});
287
- const virtualRoot = commonAncestor(roots);
288
301
 
289
302
  function resolveScanTargets(
290
303
  input: unknown,
291
304
  ):
292
- | { ok: true; targets: ResolvedPath[]; virtual: boolean }
305
+ | { ok: true; targets: ResolvedPath[]; virtual: boolean; base: string }
293
306
  | { ok: false; error: string } {
294
- if (input !== undefined) {
295
- const resolved = resolveScoped(roots, input, allowedPaths, true);
296
- return resolved.ok
297
- ? { ok: true, targets: [resolved], virtual: false }
298
- : resolved;
307
+ const resolved = resolveScoped(
308
+ roots,
309
+ input ?? ".",
310
+ allowedPaths,
311
+ true,
312
+ workspaceRoot,
313
+ );
314
+ if (!resolved.ok) return resolved;
315
+ if (roots.some((root) => isWithinPath(resolved.abs, root))) {
316
+ return {
317
+ ok: true,
318
+ targets: [resolved],
319
+ virtual: false,
320
+ base: resolved.abs,
321
+ };
299
322
  }
300
323
  const targets: ResolvedPath[] = [];
301
324
  for (const root of roots) {
302
- const resolved = resolveScoped([root], ".", allowedPaths, true);
303
- if (resolved.ok) targets.push(resolved);
325
+ if (!isWithinPath(root, resolved.abs)) continue;
326
+ const target = resolveScoped(
327
+ roots,
328
+ relativePath(workspaceRoot, root),
329
+ allowedPaths,
330
+ true,
331
+ workspaceRoot,
332
+ );
333
+ if (
334
+ target.ok &&
335
+ !targets.some((existing) => isWithinPath(target.abs, existing.abs))
336
+ )
337
+ targets.push(target);
304
338
  }
305
339
  return targets.length > 0
306
- ? { ok: true, targets, virtual: roots.length > 1 }
307
- : {
308
- ok: false,
309
- error: "path is outside the declared read/write scope",
310
- };
340
+ ? { ok: true, targets, virtual: true, base: resolved.abs }
341
+ : { ok: false, error: "path is outside the declared read/write scope" };
311
342
  }
312
343
 
313
- function rootRelativePath(path: string, virtual: boolean): string {
314
- if (virtual) return relativePath(virtualRoot, path);
315
- const root = roots.find((candidate) => isWithinPath(path, candidate));
316
- return relativePath(root ?? roots[0], path);
344
+ function rootRelativePath(path: string): string {
345
+ return relativePath(workspaceRoot, path);
317
346
  }
318
347
 
319
348
  const readTool: ScopedToolDef = {
@@ -321,7 +350,13 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
321
350
  description:
322
351
  "Read a regular UTF-8 text file within the approved scope (max 2,000 lines / 50 KiB).",
323
352
  async execute(params) {
324
- const resolved = resolveScoped(roots, params.path, allowedPaths);
353
+ const resolved = resolveScoped(
354
+ roots,
355
+ params.path,
356
+ allowedPaths,
357
+ false,
358
+ workspaceRoot,
359
+ );
325
360
  if (!resolved.ok) return { ok: false, error: resolved.error };
326
361
  let st: Stats | undefined;
327
362
  try {
@@ -374,8 +409,9 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
374
409
  const grepTool: ScopedToolDef = {
375
410
  name: "grep",
376
411
  description:
377
- "Search regular text files with a bounded JavaScript regular expression (max 1,024 chars, 2,000 files).",
378
- async execute(params) {
412
+ "Search scoped UTF-8 files using a cancellable JavaScript regexp (5 seconds, 1 MiB/file, 16 MiB total, 1,000 matches / 50 KiB output); truncated results are marked.",
413
+ async execute(params, signal) {
414
+ if (signal?.aborted) return { ok: false, error: "search cancelled" };
379
415
  const pattern = params.pattern;
380
416
  if (typeof pattern !== "string" || pattern.length === 0) {
381
417
  return { ok: false, error: "missing pattern" };
@@ -386,47 +422,199 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
386
422
  error: `pattern exceeds ${TOOL_LIMITS.maxGrepPattern} characters`,
387
423
  };
388
424
  }
389
- const scan = resolveScanTargets(params.path);
390
- if (!scan.ok) return { ok: false, error: scan.error };
391
- let regex: RegExp;
392
425
  try {
393
- regex = new RegExp(pattern, "u");
426
+ new RegExp(pattern, "u");
394
427
  } catch {
395
428
  return { ok: false, error: "invalid regular expression" };
396
429
  }
397
- const start = performance.now();
398
- const matches: { path: string; line: number; text: string }[] = [];
399
- let scanned = 0;
430
+ const scan = resolveScanTargets(params.path);
431
+ if (!scan.ok) return { ok: false, error: scan.error };
432
+ const deadline = performance.now() + TOOL_LIMITS.maxGrepMillis;
400
433
  const files: string[] = [];
401
- for (const target of scan.targets) {
402
- collectFiles(target.abs, files, TOOL_LIMITS.maxEntries, allowedPaths);
403
- }
404
- files.sort((left, right) => {
405
- const leftPath = rootRelativePath(left, scan.virtual);
406
- const rightPath = rootRelativePath(right, scan.virtual);
407
- return leftPath < rightPath ? -1 : leftPath > rightPath ? 1 : 0;
408
- });
409
- for (const file of files) {
410
- if (scanned >= TOOL_LIMITS.maxGrepFiles) break;
411
- if (performance.now() - start > TOOL_LIMITS.maxGrepMillis) break;
412
- scanned++;
413
- const text = utf8Text(file);
414
- if (text === null) continue;
415
- const path = rootRelativePath(file, scan.virtual);
416
- observe({ kind: "file", path });
417
- const lines = text.split("\n");
418
- for (let i = 0; i < lines.length; i++) {
419
- regex.lastIndex = 0;
420
- if (regex.test(lines[i])) {
421
- matches.push({
434
+ const matches: Array<{ path: string; line: number; text: string }> = [];
435
+ let truncated = false;
436
+ let visited = 0;
437
+ let scanBytes = 0;
438
+ let resultBytes = 0;
439
+ let worker: Worker | undefined;
440
+ const checkSearch = () => {
441
+ if (signal?.aborted) throw new Error("search cancelled");
442
+ if (performance.now() >= deadline) throw new Error("search timed out");
443
+ };
444
+ const collectSearchFiles = async (directory: string): Promise<void> => {
445
+ checkSearch();
446
+ const target = await lstat(directory);
447
+ checkSearch();
448
+ if (target.isFile()) {
449
+ if (isAllowedContent(directory, allowedPaths)) files.push(directory);
450
+ return;
451
+ }
452
+ if (!target.isDirectory()) return;
453
+ const entries = await opendir(directory);
454
+ for await (const entry of entries) {
455
+ checkSearch();
456
+ if (
457
+ ++visited > TOOL_LIMITS.maxEntries ||
458
+ files.length >= TOOL_LIMITS.maxGrepFiles
459
+ ) {
460
+ truncated = true;
461
+ break;
462
+ }
463
+ if (isHidden(entry.name, roots)) continue;
464
+ const full = join(directory, entry.name);
465
+ if (entry.isDirectory() && isRelatedToAllowedPath(full, allowedPaths))
466
+ await collectSearchFiles(full);
467
+ else if (entry.isFile() && isAllowedContent(full, allowedPaths))
468
+ files.push(full);
469
+ }
470
+ };
471
+ try {
472
+ for (const target of scan.targets) await collectSearchFiles(target.abs);
473
+ const ordered = [...new Set(files)].sort((left, right) => {
474
+ const a = rootRelativePath(left);
475
+ const b = rootRelativePath(right);
476
+ return a < b ? -1 : a > b ? 1 : 0;
477
+ });
478
+ if (ordered.length > TOOL_LIMITS.maxGrepFiles) truncated = true;
479
+ checkSearch();
480
+ worker = new Worker(
481
+ new URL("./scoped-grep-worker.mjs", import.meta.url),
482
+ {
483
+ resourceLimits: { maxOldGenerationSizeMb: 64 },
484
+ },
485
+ );
486
+ // Keep one rejection path installed between requests as well, so a late
487
+ // worker error cannot become an uncaught EventEmitter error.
488
+ let workerFailed = false;
489
+ worker.on("error", () => {
490
+ workerFailed = true;
491
+ });
492
+ for (const file of ordered.slice(0, TOOL_LIMITS.maxGrepFiles)) {
493
+ checkSearch();
494
+ if (scanBytes >= TOOL_LIMITS.maxGrepScanBytes) {
495
+ truncated = true;
496
+ break;
497
+ }
498
+ const limit = Math.min(
499
+ TOOL_LIMITS.maxGrepFileBytes,
500
+ TOOL_LIMITS.maxGrepScanBytes - scanBytes,
501
+ );
502
+ // Read at most the budget plus one byte, even if the file grows after
503
+ // admission. Oversized files are skipped explicitly, never decoded in full.
504
+ const buffer = Buffer.alloc(limit + 1);
505
+ const path = rootRelativePath(file);
506
+ if (observeSafePath(workspaceRoot, path).kind !== "file") {
507
+ truncated = true;
508
+ continue;
509
+ }
510
+ const fd = await open(
511
+ file,
512
+ constants.O_RDONLY |
513
+ (constants.O_NOFOLLOW ?? 0) |
514
+ (constants.O_NONBLOCK ?? 0),
515
+ );
516
+ let length = 0;
517
+ try {
518
+ if (!(await fd.stat()).isFile()) {
519
+ truncated = true;
520
+ continue;
521
+ }
522
+ while (length < buffer.length) {
523
+ checkSearch();
524
+ const { bytesRead: count } = await fd.read(
525
+ buffer,
526
+ length,
527
+ buffer.length - length,
528
+ null,
529
+ );
530
+ if (count === 0) break;
531
+ length += count;
532
+ }
533
+ } finally {
534
+ await fd.close();
535
+ }
536
+ checkSearch();
537
+ scanBytes += Math.min(length, limit);
538
+ if (length > limit) {
539
+ truncated = true;
540
+ continue;
541
+ }
542
+ let text: string;
543
+ try {
544
+ text = new TextDecoder("utf-8", { fatal: true }).decode(
545
+ buffer.subarray(0, length),
546
+ );
547
+ } catch {
548
+ continue;
549
+ }
550
+ observe({ kind: "file", path });
551
+ if (workerFailed) return { ok: false, error: "search unavailable" };
552
+ const currentWorker = worker;
553
+ const result = await new Promise<{
554
+ matches: typeof matches;
555
+ bytes: number;
556
+ truncated: boolean;
557
+ }>((resolve, reject) => {
558
+ const cleanup = () => {
559
+ clearTimeout(timer);
560
+ signal?.removeEventListener("abort", onAbort);
561
+ currentWorker.removeListener("message", onMessage);
562
+ currentWorker.removeListener("error", onError);
563
+ currentWorker.removeListener("exit", onError);
564
+ };
565
+ const fail = (message: string) => {
566
+ cleanup();
567
+ reject(new Error(message));
568
+ };
569
+ const onAbort = () => fail("search cancelled");
570
+ const onError = () => fail("search unavailable");
571
+ const onMessage = (value: {
572
+ matches: typeof matches;
573
+ bytes: number;
574
+ truncated: boolean;
575
+ }) => {
576
+ cleanup();
577
+ resolve(value);
578
+ };
579
+ const timer = setTimeout(
580
+ () => fail("search timed out"),
581
+ Math.max(1, deadline - performance.now()),
582
+ );
583
+ signal?.addEventListener("abort", onAbort, { once: true });
584
+ currentWorker.once("message", onMessage);
585
+ currentWorker.once("error", onError);
586
+ currentWorker.once("exit", onError);
587
+ if (signal?.aborted) {
588
+ onAbort();
589
+ return;
590
+ }
591
+ currentWorker.postMessage({
592
+ pattern,
422
593
  path,
423
- line: i + 1,
424
- text: lines[i].slice(0, 500),
594
+ text,
595
+ maxMatches: TOOL_LIMITS.maxGrepMatches - matches.length,
596
+ maxBytes: TOOL_LIMITS.maxGrepResultBytes - resultBytes,
425
597
  });
598
+ });
599
+ matches.push(...result.matches);
600
+ resultBytes += result.bytes;
601
+ if (result.truncated) {
602
+ truncated = true;
603
+ break;
426
604
  }
427
605
  }
606
+ return { ok: true, matches, truncated };
607
+ } catch (error) {
608
+ const message = error instanceof Error ? error.message : "";
609
+ return {
610
+ ok: false,
611
+ error: ["search cancelled", "search timed out"].includes(message)
612
+ ? message
613
+ : "search unavailable",
614
+ };
615
+ } finally {
616
+ await worker?.terminate();
428
617
  }
429
- return { ok: true, matches };
430
618
  },
431
619
  };
432
620
 
@@ -447,13 +635,13 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
447
635
  if (!st.isDirectory()) return { ok: false, error: "not a directory" };
448
636
  observe({
449
637
  kind: "dir",
450
- path: rootRelativePath(target.abs, scan.virtual),
638
+ path: rootRelativePath(target.abs),
451
639
  });
452
640
  }
453
641
  if (scan.virtual) {
454
642
  const names = new Set(
455
643
  scan.targets.map(
456
- (target) => relativePath(virtualRoot, target.abs).split("/")[0],
644
+ (target) => relativePath(scan.base, target.abs).split("/")[0],
457
645
  ),
458
646
  );
459
647
  return {
@@ -485,7 +673,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
485
673
  const findTool: ScopedToolDef = {
486
674
  name: "find",
487
675
  description:
488
- "Search for files by glob pattern within the approved scope; paths are relative to the search directory (max 20,000 entries).",
676
+ "Search for files by glob pattern within the approved scope; returned paths are workspace-relative (max 20,000 entries).",
489
677
  async execute(params) {
490
678
  const pattern = params.pattern;
491
679
  if (typeof pattern !== "string" || pattern.length === 0) {
@@ -510,7 +698,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
510
698
  if (!st.isDirectory()) return { ok: false, error: "not a directory" };
511
699
  observe({
512
700
  kind: "dir",
513
- path: rootRelativePath(target.abs, scan.virtual),
701
+ path: rootRelativePath(target.abs),
514
702
  });
515
703
  }
516
704
  const matcher = globToRegExp(pattern);
@@ -521,11 +709,12 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
521
709
  for (const target of scan.targets) {
522
710
  collectFiles(target.abs, files, TOOL_LIMITS.maxEntries, allowedPaths);
523
711
  }
524
- const findBase = scan.virtual ? virtualRoot : scan.targets[0].abs;
525
- const out = files.map((abs) => relativePath(findBase, abs));
526
- const entries = out.filter(
527
- (rel) => matcher.test(rel) || matcher.test(basename(rel)),
528
- );
712
+ const entries = [...new Set(files)]
713
+ .filter((abs) => {
714
+ const searched = relativePath(scan.base, abs);
715
+ return matcher.test(searched) || matcher.test(basename(abs));
716
+ })
717
+ .map((abs) => rootRelativePath(abs));
529
718
  return {
530
719
  ok: true,
531
720
  entries: entries
@@ -540,6 +729,10 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
540
729
  out: string[],
541
730
  limit: number,
542
731
  scopedPaths?: string[],
732
+ traversal = {
733
+ remaining: TOOL_LIMITS.maxEntries as number,
734
+ truncated: false,
735
+ },
543
736
  ): string[] {
544
737
  if (out.length >= limit) return out;
545
738
  const target = statSync(dir);
@@ -550,12 +743,15 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
550
743
  if (!target.isDirectory()) return out;
551
744
  const entries = readdirSync(dir, { withFileTypes: true });
552
745
  for (const e of entries) {
553
- if (out.length >= limit) break;
746
+ if (out.length >= limit || traversal.remaining-- <= 0) {
747
+ traversal.truncated = true;
748
+ break;
749
+ }
554
750
  if (isHidden(e.name, roots)) continue;
555
751
  const full = join(dir, e.name);
556
752
  if (e.isDirectory()) {
557
753
  if (isRelatedToAllowedPath(full, scopedPaths)) {
558
- collectFiles(full, out, limit, scopedPaths);
754
+ collectFiles(full, out, limit, scopedPaths, traversal);
559
755
  }
560
756
  } else if (e.isFile() && isAllowedContent(full, scopedPaths)) {
561
757
  out.push(full);
@@ -568,7 +764,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
568
764
  if (name !== ".git" && name !== "node_modules") return false;
569
765
  // Explicit scope: the approved root itself is the hidden directory.
570
766
  for (const scope of scopes) {
571
- if (basename(realpathSync(scope)) === name) return false;
767
+ if (basename(scope) === name) return false;
572
768
  }
573
769
  return true;
574
770
  }