@abelxiaoxing/cadence 1.2.4 → 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.
- package/README.md +155 -10
- package/package.json +5 -3
- package/prompts/abel-design.md +71 -17
- package/prompts/abel-diagnose.md +10 -1
- package/prompts/abel-implement.md +118 -20
- package/prompts/abel-init.md +9 -2
- package/src/apply-transaction.ts +16 -14
- package/src/artifact-store.ts +17 -0
- package/src/candidate-patch.ts +2 -1
- package/src/canonical.ts +16 -0
- package/src/change-contract.ts +268 -0
- package/src/child-session.ts +4 -3
- package/src/contracts.ts +141 -33
- package/src/control-contracts.ts +51 -1
- package/src/delivery-compiler.ts +148 -31
- package/src/design-control.ts +83 -15
- package/src/design-journal.ts +216 -72
- package/src/durable-workflow.ts +4385 -0
- package/src/implement-graph.ts +11 -16
- package/src/index.ts +399 -1079
- package/src/isolation-backend.ts +84 -23
- package/src/openspec-cli.ts +494 -0
- package/src/package-delivery.ts +486 -0
- package/src/package-verification.ts +509 -0
- package/src/route-policy.ts +1 -11
- package/src/run-state.ts +4 -16
- package/src/run-store.ts +5 -144
- package/src/scoped-grep-worker.mjs +23 -0
- package/src/scoped-tools.ts +312 -116
- package/src/sqlite-schema.ts +171 -0
- package/src/storage-schema.ts +352 -0
- package/src/subagent-activity.ts +14 -1
- package/src/submit-tool.ts +20 -2
- package/src/task-ledger.ts +14 -55
- package/src/verification-capability.ts +298 -102
- package/src/verification-environment.ts +213 -0
- package/src/verification-identity.ts +77 -0
- package/src/worker-broker.ts +97 -34
- package/src/workflow-engine.ts +28 -8285
- package/src/workflow-policy.ts +1427 -0
- package/src/workflow-state-machine.ts +4478 -0
- package/src/workspace-io-entry.mjs +58 -0
- package/src/workspace-io-worker.mjs +984 -0
- package/src/workspace-io.ts +84 -0
- package/src/workspace-store.ts +125 -19
- package/skills/abel-workflow/SKILL.md +0 -205
package/src/scoped-tools.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
: "path
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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:
|
|
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
|
|
314
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
426
|
+
new RegExp(pattern, "u");
|
|
394
427
|
} catch {
|
|
395
428
|
return { ok: false, error: "invalid regular expression" };
|
|
396
429
|
}
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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)
|
|
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(
|
|
767
|
+
if (basename(scope) === name) return false;
|
|
572
768
|
}
|
|
573
769
|
return true;
|
|
574
770
|
}
|