@robota-sdk/agent-tools 3.0.0-beta.79 → 3.0.0-beta.81

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.
@@ -23,15 +23,20 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  //#endregion
24
24
  let node_fs_promises = require("node:fs/promises");
25
25
  let node_path = require("node:path");
26
- let _robota_sdk_agent_core = require("@robota-sdk/agent-core");
27
26
  let node_child_process = require("node:child_process");
28
- let _robota_sdk_agent_process = require("@robota-sdk/agent-process");
29
- let zod = require("zod");
30
27
  let node_crypto = require("node:crypto");
28
+ let node_fs = require("node:fs");
29
+ let node_os = require("node:os");
30
+ let _robota_sdk_agent_core = require("@robota-sdk/agent-core");
31
+ let zod = require("zod");
32
+ let _robota_sdk_agent_process = require("@robota-sdk/agent-process");
33
+ let _robota_sdk_agent_core_node = require("@robota-sdk/agent-core/node");
31
34
  let fast_glob = require("fast-glob");
32
35
  fast_glob = __toESM(fast_glob, 1);
33
36
  let p_limit = require("p-limit");
34
37
  p_limit = __toESM(p_limit, 1);
38
+ let node_events = require("node:events");
39
+ let node_worker_threads = require("node:worker_threads");
35
40
  //#region src/sandbox/e2b-sandbox-client.ts
36
41
  var E2BSandboxClient = class {
37
42
  sandbox;
@@ -133,12 +138,42 @@ var InMemorySandboxClient = class {
133
138
  }
134
139
  };
135
140
  //#endregion
141
+ //#region src/sandbox/containment.ts
142
+ function describeExecutionContainment(client) {
143
+ if (client === void 0) return "host";
144
+ return `sandbox-${client.filesystem ?? "separate"}`;
145
+ }
146
+ /** Whether file tools must read and write through the sandbox rather than the host filesystem. */
147
+ function routesFilesThroughSandbox(client) {
148
+ return describeExecutionContainment(client) === "sandbox-separate";
149
+ }
150
+ //#endregion
151
+ //#region src/sandbox/manifest-enforceability.ts
152
+ /**
153
+ * Refuse a manifest whose security-bearing fields the built-in applicator cannot enforce.
154
+ *
155
+ * Emptiness is what is checked, not presence: `environment: {}` and `permissions: {}` request
156
+ * nothing, so refusing them would fail a caller that asked for no controls at all. `permissions`
157
+ * counts as empty when neither list has an entry — `{ read: [] }` is a declared-but-empty policy,
158
+ * not a policy.
159
+ */
160
+ function refuseUnenforceableManifestControls(manifest) {
161
+ const unenforceable = [];
162
+ if (manifest.environment && Object.keys(manifest.environment).length > 0) unenforceable.push("environment");
163
+ const permissions = manifest.permissions;
164
+ const requestsSomething = (value) => Array.isArray(value) ? value.length > 0 : value !== void 0;
165
+ if (permissions && Object.values(permissions).some(requestsSomething)) unenforceable.push("permissions");
166
+ if (unenforceable.length === 0) return;
167
+ throw new Error(`workspace manifest requests ${unenforceable.join(" and ")}, which this sandbox client cannot enforce. The built-in applicator applies entries only. Supply a sandbox client that implements applyManifest and honours these fields, or remove them from the manifest — they were previously accepted and silently ignored, which reported a sandbox policy that was never applied (issue #2027).`);
168
+ }
169
+ //#endregion
136
170
  //#region src/sandbox/workspace-manifest.ts
137
171
  const DEFAULT_TARGET_ROOT = "/workspace";
138
172
  const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
139
173
  const SHELL_QUOTE_PATTERN = /'/g;
140
174
  async function applyWorkspaceManifest(sandboxClient, manifest, options = {}) {
141
175
  if (sandboxClient.applyManifest) return sandboxClient.applyManifest(manifest, options);
176
+ refuseUnenforceableManifestControls(manifest);
142
177
  const targetRoot = normalizeSandboxRoot(options.targetRoot ?? DEFAULT_TARGET_ROOT);
143
178
  const appliedEntries = [];
144
179
  for (const [rawPath, entry] of Object.entries(manifest.entries)) {
@@ -235,8 +270,20 @@ async function runSandboxCommand(sandboxClient, command) {
235
270
  function resolveHostSourcePath(source, hostRoot) {
236
271
  return (0, node_path.isAbsolute)(source) ? (0, node_path.resolve)(source) : (0, node_path.resolve)(hostRoot ?? process.cwd(), source);
237
272
  }
273
+ /**
274
+ * Remove every trailing `/`, by index scan.
275
+ *
276
+ * Not `replace(/\/+$/, '')`: that run has no start anchor, so the engine retries it from every offset inside the
277
+ * run and each retry re-scans to the end — 3.0 s on a 100 K run (`js/polynomial-redos`, SEC-003). The backslash
278
+ * conversion in {@link normalizeSandboxRoot} manufactures such a run from a Windows-style path.
279
+ */
280
+ function trimTrailingSlashes(value) {
281
+ let end = value.length;
282
+ while (end > 0 && value[end - 1] === "/") end -= 1;
283
+ return value.slice(0, end);
284
+ }
238
285
  function normalizeSandboxRoot(root) {
239
- const normalized = root.replace(/\\/g, "/").replace(/\/+$/, "");
286
+ const normalized = trimTrailingSlashes(root.replace(/\\/g, "/"));
240
287
  if (!normalized.startsWith("/")) throw new Error("workspace manifest targetRoot must be an absolute sandbox path");
241
288
  return normalized.length === 0 ? "/" : normalized;
242
289
  }
@@ -252,291 +299,745 @@ function assertUnreachable(value) {
252
299
  throw new Error(`unsupported workspace manifest entry: ${JSON.stringify(value)}`);
253
300
  }
254
301
  //#endregion
255
- //#region src/registry/tool-registry.ts
302
+ //#region src/sandbox/os-sandbox-policy.ts
256
303
  /**
257
- * Tool registry implementation
258
- * Manages tool registration, validation, and retrieval
304
+ * What an OS-level sandbox lets a command touch, written once per backend (issue #3082).
305
+ *
306
+ * The same policy becomes bubblewrap arguments on Linux and a Seatbelt profile on macOS:
307
+ * - the whole filesystem is readable except the `denyRead` paths;
308
+ * - writes are allowed only inside the workspace, the temporary directories and `allowWrite`;
309
+ * - inside the workspace, the files that configure git, the agent, MCP servers and shells stay
310
+ * read-only, so a confined command cannot change what the next session trusts;
311
+ * - the network is either reachable or not. There is no per-domain allowlist: that needs a proxy
312
+ * process the OS cannot enforce, and a boundary here is only worth what the OS enforces.
259
313
  */
260
- var ToolRegistry = class {
261
- tools = /* @__PURE__ */ new Map();
262
- /**
263
- * Register a tool
264
- */
265
- register(tool) {
266
- if (!tool.schema?.name) throw new _robota_sdk_agent_core.ValidationError("Tool must have a valid schema with name");
267
- const toolName = tool.schema.name;
268
- this.validateToolSchema(tool.schema);
269
- if (this.tools.has(toolName)) _robota_sdk_agent_core.logger.warn(`Tool "${toolName}" is already registered, overriding`, {
270
- toolName,
271
- existingTool: this.tools.get(toolName)?.constructor.name
272
- });
273
- this.tools.set(toolName, tool);
274
- _robota_sdk_agent_core.logger.debug(`Tool "${toolName}" registered successfully`, {
275
- toolName,
276
- toolType: tool.constructor.name,
277
- parameters: Object.keys(tool.schema.parameters?.properties || {})
278
- });
279
- }
280
- /**
281
- * Unregister a tool
282
- */
283
- unregister(name) {
284
- if (!this.tools.has(name)) {
285
- _robota_sdk_agent_core.logger.warn(`Attempted to unregister non-existent tool "${name}"`);
286
- return;
314
+ /** An isolated worktree's files are ordinary workspace files. */
315
+ const WRITABLE_INSIDE_PROTECTED = [".robota/worktrees", ".claude/worktrees"];
316
+ function join$2(root, relative) {
317
+ let end = root.length;
318
+ while (end > 0 && root[end - 1] === "/") end -= 1;
319
+ return `${root.slice(0, end)}/${relative}`;
320
+ }
321
+ /**
322
+ * Workspace entries a confined command must not write, relative to the root. `.git` is read-only
323
+ * as a whole: the files that make git run something (config, hooks, `commondir`, per-worktree
324
+ * config) are too many and too easy to add to for a list inside it to stay complete, so git
325
+ * commands that write run unconfined, through the ordinary permission path.
326
+ */
327
+ function protectedWorkspaceEntries() {
328
+ return [..._robota_sdk_agent_core.PROTECTED_DIRECTORY_NAMES, ..._robota_sdk_agent_core.PROTECTED_FILE_NAMES];
329
+ }
330
+ /** The `bwrap` argument vector that runs `command args` under the policy. */
331
+ function bubblewrapArguments(input) {
332
+ const { policy } = input;
333
+ const args = [
334
+ "--ro-bind",
335
+ "/",
336
+ "/",
337
+ "--dev",
338
+ "/dev",
339
+ "--proc",
340
+ "/proc"
341
+ ];
342
+ for (const path of [
343
+ policy.root,
344
+ ...policy.tempDirectories,
345
+ ...policy.allowWrite
346
+ ]) args.push("--bind-try", path, path);
347
+ for (const entry of protectedWorkspaceEntries()) {
348
+ const path = join$2(policy.root, entry);
349
+ if (input.exists(path)) args.push("--ro-bind", path, path);
350
+ }
351
+ for (const entry of WRITABLE_INSIDE_PROTECTED) {
352
+ const path = join$2(policy.root, entry);
353
+ if (!input.exists(path)) continue;
354
+ args.push("--bind", path, path);
355
+ for (const name of input.listDirectory(path)) {
356
+ const gitFile = join$2(path, `${name}/.git`);
357
+ if (input.exists(gitFile)) args.push("--ro-bind", gitFile, gitFile);
287
358
  }
288
- this.tools.delete(name);
289
- _robota_sdk_agent_core.logger.debug(`Tool "${name}" unregistered successfully`);
290
359
  }
291
- /**
292
- * Get tool by name
293
- */
294
- get(name) {
295
- return this.tools.get(name);
360
+ for (const hidden of policy.denyRead) {
361
+ if (!input.exists(hidden.path)) continue;
362
+ if (hidden.directory) args.push("--tmpfs", hidden.path);
363
+ else args.push("--ro-bind", "/dev/null", hidden.path);
296
364
  }
297
- /**
298
- * Get all registered tools
299
- */
300
- getAll() {
301
- return Array.from(this.tools.values());
365
+ if (!policy.network) {
366
+ if (input.seccompDescriptor === void 0) throw new Error("A sandbox without network needs the Unix-socket seccomp filter.");
367
+ args.push("--unshare-net", "--seccomp", String(input.seccompDescriptor));
302
368
  }
303
- /**
304
- * Get tool schemas
305
- */
306
- getSchemas() {
307
- const tools = this.getAll();
308
- _robota_sdk_agent_core.logger.debug("[TOOL-FLOW] ToolRegistry.getSchemas() - Tools before schema extraction", {
309
- count: tools.length,
310
- tools: tools.map((t) => ({
311
- name: t.schema?.name ?? "unnamed",
312
- hasSchema: !!t.schema,
313
- schemaType: typeof t.schema,
314
- toolType: t.constructor?.name || "unknown"
315
- }))
316
- });
317
- return this.getAll().map((tool) => tool.schema);
369
+ args.push("--unshare-pid", "--die-with-parent", "--new-session", "--chdir", input.cwd);
370
+ args.push("--", input.command);
371
+ return [...args, ...input.args];
372
+ }
373
+ function regexEscape(path) {
374
+ return path.replace(/[\\^$.*+?()[\]{}|"]/g, (char) => `\\${char}`);
375
+ }
376
+ function quote(path) {
377
+ return `"${path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
378
+ }
379
+ /**
380
+ * The Seatbelt profile for `sandbox-exec -p`. Later rules win, so the order below is the policy:
381
+ * deny writes, allow the writable places, deny the protected entries again, reopen worktrees.
382
+ */
383
+ function seatbeltProfile(policy) {
384
+ const writable = [
385
+ policy.root,
386
+ ...policy.tempDirectories,
387
+ ...policy.allowWrite
388
+ ].map((path) => `(subpath ${quote(path)})`).join(" ");
389
+ const protectedEntries = protectedWorkspaceEntries().map((entry) => {
390
+ const path = join$2(policy.root, entry);
391
+ return _robota_sdk_agent_core.PROTECTED_FILE_NAMES.includes(entry) ? `(literal ${quote(path)})` : `(subpath ${quote(path)})`;
392
+ });
393
+ const worktrees = WRITABLE_INSIDE_PROTECTED.map((entry) => `(subpath ${quote(join$2(policy.root, entry))})`);
394
+ const pinned = [`(literal ${quote(join$2(policy.root, ".git"))})`, ...WRITABLE_INSIDE_PROTECTED.map((entry) => `(regex #"^${regexEscape(join$2(policy.root, entry))}/[^/]+/\\.git$")`)];
395
+ const lines = [
396
+ "(version 1)",
397
+ "(allow default)",
398
+ "(deny file-write*)",
399
+ `(allow file-write* ${writable} (literal "/dev/null") (regex #"^/dev/tty") (regex #"^/dev/fd/"))`,
400
+ `(deny file-write* ${protectedEntries.join(" ")})`,
401
+ `(allow file-write* ${worktrees.join(" ")})`,
402
+ `(deny file-write* ${pinned.join(" ")})`
403
+ ];
404
+ if (policy.denyRead.length > 0) {
405
+ const hidden = policy.denyRead.map((entry) => entry.directory ? `(subpath ${quote(entry.path)})` : `(literal ${quote(entry.path)})`);
406
+ lines.push(`(deny file-read* ${hidden.join(" ")})`);
407
+ }
408
+ if (!policy.network) lines.push("(deny network*)");
409
+ return lines.join("\n");
410
+ }
411
+ //#endregion
412
+ //#region src/sandbox/os-sandbox-seccomp.ts
413
+ /**
414
+ * The seccomp filter bubblewrap loads when a confined command has no network (issue #3082).
415
+ *
416
+ * `--unshare-net` removes every network interface, but a Unix socket is a file: a daemon listening
417
+ * on one outside the sandbox (a container engine, the session bus, an ssh agent) is still reachable
418
+ * through the read-only filesystem, and is a way to run anything on the host. The filter refuses
419
+ * creating an `AF_UNIX` socket, and refuses `io_uring_setup`, which could create one without the
420
+ * `socket` system call. A system call from another ABI (x32, 32-bit compat) is refused whole, since
421
+ * its numbers differ and the checks below would not see it. macOS's Seatbelt `(deny network*)`
422
+ * already covers Unix sockets.
423
+ */
424
+ const BPF_LD_W_ABS = 32;
425
+ const BPF_JMP_JEQ_K = 21;
426
+ const BPF_JMP_JSET_K = 69;
427
+ const BPF_RET_K = 6;
428
+ const SECCOMP_RET_ALLOW = 2147418112;
429
+ const SECCOMP_RET_ERRNO = 327680;
430
+ const EPERM = 1;
431
+ const EAFNOSUPPORT = 97;
432
+ const AF_UNIX = 1;
433
+ const X32_SYSCALL_BIT = 1073741824;
434
+ /** `struct seccomp_data` offsets. */
435
+ const OFFSET_NR = 0;
436
+ const OFFSET_ARCH = 4;
437
+ const OFFSET_ARG0_LOW = 16;
438
+ const ARCHITECTURES = {
439
+ x64: {
440
+ audit: 3221225534,
441
+ socket: 41,
442
+ ioUringSetup: 425
443
+ },
444
+ arm64: {
445
+ audit: 3221225655,
446
+ socket: 198,
447
+ ioUringSetup: 425
318
448
  }
319
- /**
320
- * Check if tool exists
321
- */
322
- has(name) {
323
- return this.tools.has(name);
449
+ };
450
+ function instruction(code, jt, jf, k) {
451
+ return [
452
+ code,
453
+ jt,
454
+ jf,
455
+ k
456
+ ];
457
+ }
458
+ /**
459
+ * The filter as bytes `bwrap --seccomp` reads, or `undefined` for a processor architecture it has
460
+ * no system call numbers for — the caller then refuses to confine rather than confine with a gap.
461
+ */
462
+ function unixSocketSeccompFilter(arch = process.arch) {
463
+ const target = ARCHITECTURES[arch];
464
+ if (target === void 0) return void 0;
465
+ const errno = (code) => SECCOMP_RET_ERRNO | code;
466
+ const program = [
467
+ instruction(BPF_LD_W_ABS, 0, 0, OFFSET_ARCH),
468
+ instruction(BPF_JMP_JEQ_K, 1, 0, target.audit),
469
+ instruction(BPF_RET_K, 0, 0, errno(EPERM)),
470
+ instruction(BPF_LD_W_ABS, 0, 0, OFFSET_NR),
471
+ instruction(BPF_JMP_JSET_K, 0, 1, X32_SYSCALL_BIT),
472
+ instruction(BPF_RET_K, 0, 0, errno(EPERM)),
473
+ instruction(BPF_JMP_JEQ_K, 0, 1, target.ioUringSetup),
474
+ instruction(BPF_RET_K, 0, 0, errno(EPERM)),
475
+ instruction(BPF_JMP_JEQ_K, 0, 3, target.socket),
476
+ instruction(BPF_LD_W_ABS, 0, 0, OFFSET_ARG0_LOW),
477
+ instruction(BPF_JMP_JEQ_K, 0, 1, AF_UNIX),
478
+ instruction(BPF_RET_K, 0, 0, errno(EAFNOSUPPORT)),
479
+ instruction(BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW)
480
+ ];
481
+ const bytes = new Uint8Array(program.length * 8);
482
+ const view = new DataView(bytes.buffer);
483
+ program.forEach(([code, jt, jf, k], index) => {
484
+ view.setUint16(index * 8, code, true);
485
+ view.setUint8(index * 8 + 2, jt);
486
+ view.setUint8(index * 8 + 3, jf);
487
+ view.setUint32(index * 8 + 4, k >>> 0, true);
488
+ });
489
+ return bytes;
490
+ }
491
+ //#endregion
492
+ //#region src/sandbox/os-sandbox-client.ts
493
+ /**
494
+ * OS-level confinement of shell commands over the host filesystem (issue #3082): bubblewrap on
495
+ * Linux and WSL2, Seatbelt (`sandbox-exec`) on macOS. Other platforms have no backend; the client
496
+ * reports that instead of pretending.
497
+ *
498
+ * It is a `shared` sandbox client: file tools stay on the host under the path guard, and the shell
499
+ * tool starts the wrapped invocation itself. Settings are live — `/sandbox` changes them for the
500
+ * next command without rebuilding the session.
501
+ */
502
+ const DEFAULT_OS_SANDBOX_SETTINGS = Object.freeze({
503
+ enabled: false,
504
+ autoAllowBashIfSandboxed: true,
505
+ excludedCommands: [],
506
+ allowWrite: [],
507
+ denyRead: [],
508
+ network: false
509
+ });
510
+ const SEATBELT_EXECUTABLE = "/usr/bin/sandbox-exec";
511
+ function defaultProbe(command, args) {
512
+ const result = (0, node_child_process.spawnSync)(command, [...args], {
513
+ timeout: 5e3,
514
+ encoding: "utf8"
515
+ });
516
+ if (result.error !== void 0) return {
517
+ ok: false,
518
+ detail: result.error.message
519
+ };
520
+ const detail = (result.stderr ?? "").trim().split("\n")[0];
521
+ return result.status === 0 ? { ok: true } : {
522
+ ok: false,
523
+ ...detail ? { detail } : {}
524
+ };
525
+ }
526
+ /** Find the platform's backend and check it can actually start a sandbox here. */
527
+ function detectOsSandbox(options = {}) {
528
+ const platform = options.platform ?? process.platform;
529
+ const probe = options.probe ?? defaultProbe;
530
+ if (platform === "linux") {
531
+ if (unixSocketSeccompFilter(options.arch ?? process.arch) === void 0) return {
532
+ backend: "bubblewrap",
533
+ missing: [`a seccomp filter for ${options.arch ?? process.arch} (x64 and arm64 are supported)`]
534
+ };
535
+ const check = probe("bwrap", [
536
+ "--ro-bind",
537
+ "/",
538
+ "/",
539
+ "--dev",
540
+ "/dev",
541
+ "--unshare-pid",
542
+ "true"
543
+ ]);
544
+ if (check.ok) return {
545
+ backend: "bubblewrap",
546
+ executable: "bwrap",
547
+ missing: []
548
+ };
549
+ return {
550
+ backend: "bubblewrap",
551
+ missing: [check.detail?.includes("ENOENT") ? "bubblewrap (install the `bubblewrap` package)" : `bubblewrap cannot create a sandbox here${check.detail ? `: ${check.detail}` : ""}`]
552
+ };
324
553
  }
325
- /**
326
- * Clear all tools
327
- */
328
- clear() {
329
- const toolCount = this.tools.size;
330
- this.tools.clear();
331
- _robota_sdk_agent_core.logger.debug(`Cleared ${toolCount} tools from registry`);
554
+ if (platform === "darwin") {
555
+ const check = probe(SEATBELT_EXECUTABLE, [
556
+ "-p",
557
+ "(version 1)(allow default)",
558
+ "/usr/bin/true"
559
+ ]);
560
+ if (check.ok) return {
561
+ backend: "seatbelt",
562
+ executable: SEATBELT_EXECUTABLE,
563
+ missing: []
564
+ };
565
+ return {
566
+ backend: "seatbelt",
567
+ missing: [`sandbox-exec cannot run${check.detail ? `: ${check.detail}` : ""}`]
568
+ };
569
+ }
570
+ return {
571
+ missing: [],
572
+ unsupportedPlatform: platform
573
+ };
574
+ }
575
+ function realPathOrSelf(path) {
576
+ try {
577
+ return (0, node_fs.realpathSync)(path);
578
+ } catch {
579
+ return path;
580
+ }
581
+ }
582
+ function isDirectory(path) {
583
+ try {
584
+ return (0, node_fs.statSync)(path).isDirectory();
585
+ } catch {
586
+ return false;
587
+ }
588
+ }
589
+ /** Give the owner read, write and search permission throughout an entry, never following links. */
590
+ function grantOwnerAccess(path) {
591
+ const stat = (0, node_fs.lstatSync)(path);
592
+ if (stat.isSymbolicLink()) return;
593
+ (0, node_fs.chmodSync)(path, stat.mode | 448);
594
+ if (!stat.isDirectory()) return;
595
+ for (const name of (0, node_fs.readdirSync)(path)) grantOwnerAccess(`${path}/${name}`);
596
+ }
597
+ function isSymbolicLink(path) {
598
+ try {
599
+ return (0, node_fs.lstatSync)(path).isSymbolicLink();
600
+ } catch {
601
+ return false;
602
+ }
603
+ }
604
+ /** The program a shell line starts first — what `excludedCommands` names. */
605
+ function firstProgram(shellCommand) {
606
+ return shellCommand.trim().split(/\s+/)[0];
607
+ }
608
+ var OsSandboxClient = class {
609
+ filesystem = "shared";
610
+ root;
611
+ availability;
612
+ homeDirectory;
613
+ current;
614
+ inFlight = 0;
615
+ baseline = [];
616
+ /** Entries a clean-up could not restore, with the state they must return to. */
617
+ unresolved = /* @__PURE__ */ new Map();
618
+ constructor(options) {
619
+ this.root = realPathOrSelf(options.root);
620
+ this.availability = options.availability;
621
+ this.homeDirectory = options.homeDirectory ?? (0, node_os.homedir)();
622
+ this.current = {
623
+ ...DEFAULT_OS_SANDBOX_SETTINGS,
624
+ ...options.settings
625
+ };
626
+ }
627
+ status() {
628
+ return {
629
+ settings: this.current,
630
+ availability: this.availability,
631
+ active: this.current.enabled && this.availability.executable !== void 0
632
+ };
633
+ }
634
+ /** Change the settings for the next command. */
635
+ configure(settings) {
636
+ this.current = {
637
+ ...this.current,
638
+ ...settings
639
+ };
640
+ }
641
+ /** Whether `shellCommand` would run confined. */
642
+ confines(shellCommand) {
643
+ if (!this.status().active) return false;
644
+ if ((0, _robota_sdk_agent_core.splitCommandSegments)(shellCommand).length !== 1) return true;
645
+ const program = firstProgram(shellCommand);
646
+ return program === void 0 || !this.current.excludedCommands.includes(program);
647
+ }
648
+ autoApproves(shellCommand) {
649
+ if (!this.current.autoAllowBashIfSandboxed || !this.confines(shellCommand)) return false;
650
+ if (this.unresolved.size > 0) return false;
651
+ return !this.protectedEntryStates().some((state) => state.kind === "symlink" && !this.resolvesOutsideWritableWorkspace(state.path));
652
+ }
653
+ wrapCommand(invocation, shellCommand) {
654
+ if (!this.confines(shellCommand)) return invocation;
655
+ const policy = this.policy();
656
+ const executable = this.availability.executable;
657
+ if (this.availability.backend === "seatbelt") return {
658
+ command: executable,
659
+ args: [
660
+ "-p",
661
+ seatbeltProfile(policy),
662
+ invocation.command,
663
+ ...invocation.args
664
+ ],
665
+ cwd: invocation.cwd
666
+ };
667
+ const filter = policy.network ? void 0 : unixSocketSeccompFilter();
668
+ if (!this.protectedEntryStates().some((state) => state.path.endsWith("/.robota") && state.kind === "symlink")) (0, node_fs.mkdirSync)(`${this.root}/.robota`, { recursive: true });
669
+ const args = bubblewrapArguments({
670
+ policy,
671
+ exists: (path) => (0, node_fs.existsSync)(path) && !isSymbolicLink(path),
672
+ listDirectory: (path) => (0, node_fs.readdirSync)(path),
673
+ cwd: invocation.cwd,
674
+ command: invocation.command,
675
+ args: invocation.args,
676
+ ...filter !== void 0 ? { seccompDescriptor: 3 } : {}
677
+ });
678
+ if (this.inFlight === 0) this.baseline = this.protectedEntryStates().map((state) => this.unresolved.get(state.path) ?? state);
679
+ this.inFlight += 1;
680
+ let finished = false;
681
+ return {
682
+ command: executable,
683
+ args,
684
+ cwd: invocation.cwd,
685
+ ...filter !== void 0 ? { inputDescriptors: [filter] } : {},
686
+ afterExit: () => {
687
+ if (finished) return void 0;
688
+ finished = true;
689
+ this.inFlight -= 1;
690
+ return this.restoreProtectedEntries(this.baseline);
691
+ }
692
+ };
332
693
  }
333
694
  /**
334
- * Get tool names
695
+ * How each protected entry stands before a command: bubblewrap can mount an existing entry
696
+ * read-only, but not one that does not exist yet, and a symlink it mounts through to its target
697
+ * while the link itself stays replaceable. Read with `lstat`, so a dangling link is not "missing".
335
698
  */
336
- getToolNames() {
337
- return Array.from(this.tools.keys());
699
+ protectedEntryStates() {
700
+ return protectedWorkspaceEntries().map((entry) => {
701
+ const path = `${this.root}/${entry}`;
702
+ try {
703
+ return (0, node_fs.lstatSync)(path).isSymbolicLink() ? {
704
+ path,
705
+ kind: "symlink",
706
+ target: (0, node_fs.readlinkSync)(path)
707
+ } : {
708
+ path,
709
+ kind: "present"
710
+ };
711
+ } catch {
712
+ return {
713
+ path,
714
+ kind: "missing"
715
+ };
716
+ }
717
+ });
338
718
  }
339
719
  /**
340
- * Get tools by pattern
720
+ * Undo what the command did to protected entries it could reach: one it created where none
721
+ * existed is moved into `.robota/sandbox-quarantine`, and a symlink it replaced is restored. Moved,
722
+ * not deleted, so nothing the host wrote meanwhile is lost.
341
723
  */
342
- getToolsByPattern(pattern) {
343
- const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
344
- return this.getAll().filter((tool) => regex.test(tool.schema.name));
724
+ /** Whether a path's real location is under the read-only mounts: not the workspace, temp or `allowWrite`. */
725
+ resolvesOutsideWritableWorkspace(path) {
726
+ let real;
727
+ try {
728
+ real = (0, node_fs.realpathSync)(path);
729
+ } catch {
730
+ return false;
731
+ }
732
+ const policy = this.policy();
733
+ return ![
734
+ policy.root,
735
+ ...policy.tempDirectories,
736
+ ...policy.allowWrite
737
+ ].some((area) => real === area || real.startsWith(`${area}/`));
345
738
  }
346
739
  /**
347
- * Get tool count
740
+ * Never throws: this runs as the command's process closes, and an exception there would take the
741
+ * host down and leave the entry in place. The quarantine is outside the workspace, under the
742
+ * user's `~/.robota`, where the command cannot reach it; what cannot be moved there is removed.
348
743
  */
349
- size() {
350
- return this.tools.size;
744
+ restoreProtectedEntries(before) {
745
+ const quarantine = `${this.quarantineRoot(before)}/${Date.now()}-${(0, node_crypto.randomUUID)()}`;
746
+ const notes = [];
747
+ for (const state of before) {
748
+ if (state.kind === "present") continue;
749
+ try {
750
+ const now = this.protectedEntryStates().find((entry) => entry.path === state.path);
751
+ if (state.kind === "missing" && now.kind === "missing") {
752
+ this.unresolved.delete(state.path);
753
+ continue;
754
+ }
755
+ if (state.kind === "symlink" && now.kind === "symlink" && now.target === state.target) {
756
+ this.unresolved.delete(state.path);
757
+ continue;
758
+ }
759
+ if (now.kind !== "missing") notes.push(this.setAside(state.path, quarantine));
760
+ if (state.kind === "symlink") (0, node_fs.symlinkSync)(state.target, state.path);
761
+ this.unresolved.delete(state.path);
762
+ } catch (error) {
763
+ this.unresolved.set(state.path, state);
764
+ notes.push(`could not restore ${state.path} (${error instanceof Error ? error.message : String(error)}); commands will ask until it is removed`);
765
+ }
766
+ }
767
+ if (notes.length === 0) return void 0;
768
+ return `[sandbox] A confined command may not create or replace git, agent, MCP or shell configuration: ${notes.join("; ")}.`;
351
769
  }
352
770
  /**
353
- * Validate tool schema
771
+ * Where set-aside entries go: the workspace's own `.robota`, when it was a real directory before
772
+ * the command — then it was mounted read-only, so the command could not reach it, and a rename
773
+ * within one filesystem needs no permission inside the entry. Otherwise the user's `~/.robota`.
774
+ * Decided from the baseline: what is there now may be the command's own replacement.
354
775
  */
355
- validateToolSchema(schema) {
356
- if (!schema.name || typeof schema.name !== "string") throw new _robota_sdk_agent_core.ValidationError("Tool schema must have a valid name");
357
- if (!schema.description || typeof schema.description !== "string") throw new _robota_sdk_agent_core.ValidationError("Tool schema must have a description");
358
- if (!schema.parameters || typeof schema.parameters !== "object" || schema.parameters === null || Array.isArray(schema.parameters)) throw new _robota_sdk_agent_core.ValidationError("Tool schema must have parameters object");
359
- if (schema.parameters.type !== "object") throw new _robota_sdk_agent_core.ValidationError("Tool parameters type must be \"object\"");
360
- if (schema.parameters.properties) for (const propName of Object.keys(schema.parameters.properties)) {
361
- const propSchema = schema.parameters.properties[propName];
362
- if (!propSchema?.type) throw new _robota_sdk_agent_core.ValidationError(`Parameter "${propName}" must have a type`);
363
- if (![
364
- "string",
365
- "number",
366
- "boolean",
367
- "array",
368
- "object"
369
- ].includes(propSchema.type)) throw new _robota_sdk_agent_core.ValidationError(`Parameter "${propName}" has invalid type "${propSchema.type}"`);
370
- }
371
- if (schema.parameters.required) {
372
- const properties = schema.parameters.properties || {};
373
- for (const requiredField of schema.parameters.required) if (!properties[requiredField]) throw new _robota_sdk_agent_core.ValidationError(`Required parameter "${requiredField}" is not defined in properties`);
776
+ quarantineRoot(before) {
777
+ const robota = `${this.root}/.robota`;
778
+ return before.some((state) => state.path === robota && state.kind === "present") ? `${robota}/sandbox-quarantine` : `${this.homeDirectory}/.robota/sandbox-quarantine`;
779
+ }
780
+ setAside(path, quarantine) {
781
+ const destination = `${quarantine}/${(0, node_path.basename)(path)}`;
782
+ (0, node_fs.mkdirSync)(quarantine, { recursive: true });
783
+ try {
784
+ (0, node_fs.renameSync)(path, destination);
785
+ } catch (error) {
786
+ if (this.inFlight > 0) throw error;
787
+ grantOwnerAccess(path);
788
+ if (error.code === "EXDEV") {
789
+ (0, node_fs.cpSync)(path, destination, {
790
+ recursive: true,
791
+ verbatimSymlinks: true
792
+ });
793
+ (0, node_fs.rmSync)(path, {
794
+ recursive: true,
795
+ force: true
796
+ });
797
+ } else (0, node_fs.renameSync)(path, destination);
374
798
  }
799
+ return `moved ${path} to ${destination}`;
375
800
  }
376
- };
377
- //#endregion
378
- //#region src/implementations/function-tool/parameter-validator.ts
379
- /**
380
- * Validate individual parameter type against its schema.
381
- * Returns an error string if invalid, undefined if valid.
382
- */
383
- function validateParameterType(key, value, schema) {
384
- switch (schema["type"]) {
385
- case "string":
386
- if (typeof value !== "string") return `Parameter "${key}" must be a string, got ${typeof value}`;
387
- break;
388
- case "number":
389
- if (typeof value !== "number" || isNaN(value)) return `Parameter "${key}" must be a number, got ${typeof value}`;
390
- break;
391
- case "boolean":
392
- if (typeof value !== "boolean") return `Parameter "${key}" must be a boolean, got ${typeof value}`;
393
- break;
394
- case "array":
395
- if (!Array.isArray(value)) return `Parameter "${key}" must be an array, got ${typeof value}`;
396
- if (schema.items) for (let i = 0; i < value.length; i++) {
397
- const itemError = validateParameterType(`${key}[${i}]`, value[i], schema.items);
398
- if (itemError) return itemError;
801
+ /** The policy for the current settings, with every path made absolute and real. */
802
+ policy() {
803
+ const absolute = (path) => {
804
+ const expanded = path === "~" || path.startsWith("~/") ? `${this.homeDirectory}${path.slice(1)}` : path;
805
+ return realPathOrSelf((0, node_path.isAbsolute)(expanded) ? expanded : (0, node_path.resolve)(this.root, expanded));
806
+ };
807
+ const temp = [...new Set([(0, node_os.tmpdir)(), "/tmp"].filter(node_fs.existsSync).map(realPathOrSelf))];
808
+ return {
809
+ root: this.root,
810
+ tempDirectories: temp,
811
+ allowWrite: this.current.allowWrite.map(absolute),
812
+ denyRead: this.current.denyRead.map((path) => {
813
+ const resolved = absolute(path);
814
+ return {
815
+ path: resolved,
816
+ directory: isDirectory(resolved)
817
+ };
818
+ }),
819
+ network: this.current.network
820
+ };
821
+ }
822
+ run(command, options = {}) {
823
+ const shell = (0, _robota_sdk_agent_core.resolvePlatformShell)();
824
+ const cwd = options.workingDirectory ?? this.root;
825
+ const invocation = this.wrapCommand({
826
+ command: shell.command,
827
+ args: shell.commandArgs(command),
828
+ cwd
829
+ }, command);
830
+ return new Promise((resolveRun, reject) => {
831
+ const extra = invocation.inputDescriptors ?? [];
832
+ let child;
833
+ try {
834
+ child = (0, node_child_process.spawn)(invocation.command, [...invocation.args], {
835
+ cwd: invocation.cwd,
836
+ stdio: [
837
+ "ignore",
838
+ "pipe",
839
+ "pipe",
840
+ ...extra.map(() => "pipe")
841
+ ],
842
+ ...options.timeoutMs !== void 0 ? { timeout: options.timeoutMs } : {}
843
+ });
844
+ } catch (error) {
845
+ invocation.afterExit?.();
846
+ reject(error instanceof Error ? error : new Error(String(error)));
847
+ return;
399
848
  }
400
- break;
401
- case "object":
402
- if (typeof value !== "object" || value === null || Array.isArray(value)) return `Parameter "${key}" must be an object, got ${typeof value}`;
403
- break;
849
+ extra.forEach((data, index) => {
850
+ const stream = child.stdio[index + 3];
851
+ stream?.on("error", () => void 0);
852
+ stream?.end(Buffer.from(data));
853
+ });
854
+ let stdout = "";
855
+ let stderr = "";
856
+ child.stdout?.on("data", (chunk) => stdout += chunk.toString());
857
+ child.stderr?.on("data", (chunk) => stderr += chunk.toString());
858
+ child.on("error", (error) => {
859
+ invocation.afterExit?.();
860
+ reject(error);
861
+ });
862
+ child.on("close", (code) => {
863
+ const note = invocation.afterExit?.();
864
+ resolveRun({
865
+ stdout: note === void 0 ? stdout : `${stdout}\n${note}`,
866
+ ...stderr ? { stderr } : {},
867
+ exitCode: code ?? 1
868
+ });
869
+ });
870
+ });
404
871
  }
405
- if (schema.enum && schema.enum.length > 0) {
406
- const enumValues = schema.enum;
407
- let isValidEnum = false;
408
- for (const enumValue of enumValues) if (value === enumValue) {
409
- isValidEnum = true;
410
- break;
411
- }
412
- if (!isValidEnum) return `Parameter "${key}" must be one of: ${enumValues.join(", ")}, got ${value}`;
872
+ readFile(path) {
873
+ return Promise.resolve((0, node_fs.readFileSync)(path, "utf8"));
413
874
  }
875
+ writeFile(path, content) {
876
+ (0, node_fs.writeFileSync)(path, content, "utf8");
877
+ return Promise.resolve();
878
+ }
879
+ };
880
+ //#endregion
881
+ //#region src/retrieval/repo-map-index.ts
882
+ /** Persisted-schema version — bump when `IRepoMapIndex`'s serialized shape changes incompatibly. */
883
+ const REPO_MAP_INDEX_VERSION = 1;
884
+ /** Parse one corpus file into an index entry. */
885
+ function parseEntry(parser, file) {
886
+ const parsed = parser.parse(file.path, file.content);
887
+ return {
888
+ path: file.path,
889
+ definitions: parsed.definitions,
890
+ references: parsed.references
891
+ };
892
+ }
893
+ /** Parse the whole corpus once into a serializable repo-map index. */
894
+ function buildRepoMapIndex(options) {
895
+ return {
896
+ version: 1,
897
+ entries: options.corpus.map((file) => parseEntry(options.parser, file))
898
+ };
414
899
  }
415
900
  /**
416
- * Collect all validation errors for the given parameters against a schema.
901
+ * Apply corpus changes to a built index INCREMENTALLY (SELFHOST-003 P3): re-parse only the `upserted`
902
+ * files and drop `removed` paths, reusing every unchanged entry. Returns a new index (the input is not
903
+ * mutated). A file present in both `removed` and `upserted` is upserted (re-parse wins); a path repeated
904
+ * within `upserted` is de-duplicated last-wins, so the result always has one entry per path — matching a
905
+ * full rebuild (entry order does not affect ranking). Unchanged entries are REUSED BY REFERENCE; index
906
+ * entries are treated as immutable, so callers must not mutate an entry in place.
417
907
  */
418
- function getValidationErrors(parameters, schemaRequired, schemaProperties, additionalProperties) {
419
- const errors = [];
420
- for (const field of schemaRequired) if (!(field in parameters)) errors.push(`Missing required parameter: ${field}`);
421
- for (const [key, value] of Object.entries(parameters)) {
422
- const paramSchema = schemaProperties[key];
423
- if (!paramSchema) {
424
- if (additionalProperties === true) continue;
425
- if (additionalProperties && typeof additionalProperties === "object") {
426
- const additionalTypeError = validateParameterType(key, value, additionalProperties);
427
- if (additionalTypeError) errors.push(additionalTypeError);
428
- continue;
429
- }
430
- errors.push(`Unknown parameter: ${key}`);
431
- continue;
432
- }
433
- const typeError = validateParameterType(key, value, paramSchema);
434
- if (typeError) errors.push(typeError);
435
- }
436
- return errors;
908
+ function updateRepoMapIndex(index, changes, parser) {
909
+ const upsertedByPath = new Map((changes.upserted ?? []).map((file) => [file.path, file]));
910
+ const touched = /* @__PURE__ */ new Set([...changes.removed ?? [], ...upsertedByPath.keys()]);
911
+ const kept = index.entries.filter((entry) => !touched.has(entry.path));
912
+ const upserted = [...upsertedByPath.values()].map((file) => parseEntry(parser, file));
913
+ return {
914
+ version: index.version,
915
+ entries: [...kept, ...upserted]
916
+ };
917
+ }
918
+ /** Serialize a built index to a neutral JSON string for persistence by the surface. */
919
+ function serializeRepoMapIndex(index) {
920
+ return JSON.stringify(index);
437
921
  }
438
922
  /**
439
- * Validate parameters and return a structured result.
923
+ * Restore a built index from its serialized form. Throws on malformed JSON or an unsupported
924
+ * `version` — a stale/incompatible persisted index must be rebuilt, never silently mis-ranked.
440
925
  */
441
- function validateToolParameters(parameters, schemaRequired, schemaProperties, additionalProperties) {
442
- const errors = getValidationErrors(parameters, schemaRequired, schemaProperties, additionalProperties);
926
+ function deserializeRepoMapIndex(serialized) {
927
+ const parsed = JSON.parse(serialized);
928
+ if (parsed.version !== 1) throw new Error(`Unsupported repo-map index version ${String(parsed.version)} (expected 1); rebuild the index.`);
929
+ if (!Array.isArray(parsed.entries)) throw new Error("Malformed repo-map index: missing `entries`.");
930
+ for (const entry of parsed.entries) if (typeof entry?.path !== "string" || !Array.isArray(entry?.definitions) || !Array.isArray(entry?.references)) throw new Error("Malformed repo-map index: a corrupt entry — rebuild the index.");
443
931
  return {
444
- isValid: errors.length === 0,
445
- errors
932
+ version: parsed.version,
933
+ entries: parsed.entries
446
934
  };
447
935
  }
448
936
  //#endregion
449
- //#region src/implementations/function-tool.ts
937
+ //#region src/retrieval/repo-map-adapter.ts
450
938
  /**
451
- * Function tool implementation
452
- * Wraps a JavaScript function as a tool with schema validation
939
+ * SELFHOST-003: neutral repo-map ranking adapter — mirrors `InMemorySandboxClient`.
453
940
  *
454
- * Implements IFunctionTool without extending AbstractTool to avoid
455
- * circular runtime dependency (tools → agents → tools).
456
- */
457
- var FunctionTool = class {
458
- schema;
459
- fn;
460
- eventService;
461
- constructor(schema, fn) {
462
- this.schema = schema;
463
- this.fn = fn;
464
- this.validateConstructorInputs();
465
- }
466
- /**
467
- * Get tool name
468
- */
469
- getName() {
470
- return this.schema.name;
471
- }
472
- /**
473
- * Set EventService for post-construction injection.
474
- * Accepts EventService as-is without transformation.
475
- * Caller is responsible for providing properly configured EventService.
476
- */
477
- setEventService(eventService) {
478
- this.eventService = eventService;
479
- }
480
- /**
481
- * Execute the function tool
482
- */
483
- async execute(parameters, context) {
484
- const toolName = this.schema.name;
485
- if (!this.validate(parameters)) throw new _robota_sdk_agent_core.ValidationError(`Invalid parameters for tool "${toolName}": ${getValidationErrors(parameters, this.schema.parameters.required || [], this.schema.parameters.properties || {}, this.schema.parameters.additionalProperties).join(", ")}`);
486
- const startTime = Date.now();
487
- let result;
488
- try {
489
- result = await this.fn(parameters, context);
490
- } catch (error) {
491
- if (error instanceof _robota_sdk_agent_core.ToolExecutionError || error instanceof _robota_sdk_agent_core.ValidationError) throw error;
492
- throw new _robota_sdk_agent_core.ToolExecutionError(`Function tool execution failed: ${error instanceof Error ? error.message : String(error)}`, toolName, error instanceof Error ? error : new Error(String(error)), {
493
- parameterCount: Object.keys(parameters || {}).length,
494
- hasContext: !!context
495
- });
496
- }
497
- const executionTime = Date.now() - startTime;
498
- return {
499
- success: true,
500
- data: result,
501
- metadata: {
502
- executionTime,
503
- toolName,
504
- parameters
941
+ * Ranks a corpus of source files by graph centrality relative to the active files / mentioned
942
+ * identifiers, within a token budget. It is a NEUTRAL mechanism: it works on ANY repo given a corpus
943
+ * and an injected source parser — it carries no repo paths and no domain content. The heavy parser is
944
+ * injected as the duck-typed `IRetrievalSourceParser` (like `E2BSandboxClient` duck-types the E2B SDK),
945
+ * and the corpus is supplied from the surface.
946
+ *
947
+ * P2 (index build + persistence): the corpus is parsed ONCE into an `IRepoMapIndex` at construction
948
+ * (or supplied prebuilt/persisted via `{ index }`), so `retrieve()` ranks without re-parsing.
949
+ *
950
+ * Ranking model (aider repo-map style): a definition's score is the weighted number of references to it
951
+ * across the corpus, references FROM an active file weighted higher (personalization), plus a boost for
952
+ * a directly-mentioned identifier. Entries are emitted most-relevant-first, truncated to the budget.
953
+ */
954
+ /** References from an active file weigh more (personalization toward the current focus). */
955
+ const ACTIVE_FILE_WEIGHT = 3;
956
+ /** A directly-mentioned identifier is a strong relevance signal. */
957
+ const MENTION_BOOST = 5;
958
+ /**
959
+ * Estimate the token cost of one repo-map entry (neutral chars/4 heuristic). Uses the same rendering
960
+ * shape the tool prints (`file:line kind name`) so the budgeted estimate matches the emitted output.
961
+ */
962
+ function estimateTokens(symbol) {
963
+ const line = `${symbol.file}:${symbol.line} ${symbol.kind} ${symbol.name}`;
964
+ return Math.max(1, Math.ceil(line.length / 4));
965
+ }
966
+ const symbolKey = (s) => `${s.file}::${s.name}::${s.line}`;
967
+ var RepoMapRetrievalAdapter = class {
968
+ index;
969
+ constructor(options) {
970
+ if (options.index) this.index = options.index;
971
+ else if (options.parser && options.corpus) this.index = buildRepoMapIndex({
972
+ parser: options.parser,
973
+ corpus: options.corpus
974
+ });
975
+ else throw new Error("RepoMapRetrievalAdapter requires either { index } or { parser, corpus }.");
976
+ }
977
+ async retrieve(request) {
978
+ return selectWithinBudget(rankSymbols(this.index.entries.map((entry) => ({
979
+ file: entry.path,
980
+ parsed: {
981
+ definitions: entry.definitions,
982
+ references: entry.references
505
983
  }
506
- };
507
- }
508
- /**
509
- * Validate parameters (simple boolean result)
510
- */
511
- validate(parameters) {
512
- return getValidationErrors(parameters, this.schema.parameters.required || [], this.schema.parameters.properties || {}, this.schema.parameters.additionalProperties).length === 0;
513
- }
514
- /**
515
- * Validate tool parameters with detailed result
516
- */
517
- validateParameters(parameters) {
518
- return validateToolParameters(parameters, this.schema.parameters.required || [], this.schema.parameters.properties || {}, this.schema.parameters.additionalProperties);
519
- }
520
- /**
521
- * Get tool description
522
- */
523
- getDescription() {
524
- return this.schema.description;
525
- }
526
- /**
527
- * Validate constructor inputs
528
- */
529
- validateConstructorInputs() {
530
- if (!this.schema) throw new _robota_sdk_agent_core.ValidationError("Tool schema is required");
531
- if (!this.fn || typeof this.fn !== "function") throw new _robota_sdk_agent_core.ValidationError("Tool function is required and must be a function");
532
- if (!this.schema.name) throw new _robota_sdk_agent_core.ValidationError("Tool schema must have a name");
984
+ })), request), request.tokenBudget);
533
985
  }
534
986
  };
987
+ /** Index every definition in the corpus by its name (a name may be defined in several files). */
988
+ function indexDefinitions(parsed) {
989
+ const defsByName = /* @__PURE__ */ new Map();
990
+ for (const { parsed: file } of parsed) for (const def of file.definitions) {
991
+ const list = defsByName.get(def.name) ?? [];
992
+ list.push(def);
993
+ defsByName.set(def.name, list);
994
+ }
995
+ return defsByName;
996
+ }
997
+ /** Score each definition by weighted reference count + personalization + mention boost. */
998
+ function rankSymbols(parsed, request) {
999
+ const activeFiles = new Set(request.activeFiles ?? []);
1000
+ const mentioned = new Set(request.mentionedIdentifiers ?? []);
1001
+ const defsByName = indexDefinitions(parsed);
1002
+ const scoreByKey = /* @__PURE__ */ new Map();
1003
+ const bump = (s, delta) => {
1004
+ scoreByKey.set(symbolKey(s), (scoreByKey.get(symbolKey(s)) ?? 0) + delta);
1005
+ };
1006
+ for (const { file, parsed: source } of parsed) {
1007
+ const weight = activeFiles.has(file) ? ACTIVE_FILE_WEIGHT : 1;
1008
+ for (const ref of source.references) for (const def of defsByName.get(ref) ?? []) if (def.file !== file) bump(def, weight);
1009
+ }
1010
+ for (const name of mentioned) for (const def of defsByName.get(name) ?? []) bump(def, MENTION_BOOST);
1011
+ const ranked = [];
1012
+ for (const defs of defsByName.values()) for (const def of defs) ranked.push({
1013
+ ...def,
1014
+ score: scoreByKey.get(symbolKey(def)) ?? 0,
1015
+ tokens: estimateTokens(def)
1016
+ });
1017
+ ranked.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file) || a.line - b.line || a.name.localeCompare(b.name));
1018
+ return ranked;
1019
+ }
1020
+ /** Take the most-relevant-first prefix whose cumulative tokens fit the budget. */
1021
+ function selectWithinBudget(ranked, tokenBudget) {
1022
+ const symbols = [];
1023
+ let totalTokens = 0;
1024
+ for (const entry of ranked) {
1025
+ if (totalTokens + entry.tokens > tokenBudget) break;
1026
+ symbols.push(entry);
1027
+ totalTokens += entry.tokens;
1028
+ }
1029
+ return {
1030
+ symbols,
1031
+ totalTokens
1032
+ };
1033
+ }
1034
+ //#endregion
1035
+ //#region src/implementations/function-tool.ts
535
1036
  /**
536
1037
  * Helper function to create a function tool from a simple function
537
1038
  */
538
1039
  function createFunctionTool(name, description, parameters, fn) {
539
- return new FunctionTool({
1040
+ return new _robota_sdk_agent_core.FunctionTool({
540
1041
  name,
541
1042
  description,
542
1043
  parameters
@@ -545,11 +1046,12 @@ function createFunctionTool(name, description, parameters, fn) {
545
1046
  /**
546
1047
  * Helper function to create a function tool from Zod schema
547
1048
  */
548
- function createZodFunctionTool(name, description, zodSchema, fn) {
1049
+ function createZodFunctionTool(name, description, zodSchema, fn, residency = {}) {
549
1050
  const schema = {
550
1051
  name,
551
1052
  description,
552
- parameters: (0, _robota_sdk_agent_core.zodToJsonSchema)(zodSchema)
1053
+ parameters: (0, _robota_sdk_agent_core.zodToJsonSchema)(zodSchema),
1054
+ ...residency.deferLoading !== void 0 && { deferLoading: residency.deferLoading }
553
1055
  };
554
1056
  const wrappedFn = async (parameters, context) => {
555
1057
  const parseResult = zodSchema.safeParse(parameters);
@@ -557,7 +1059,461 @@ function createZodFunctionTool(name, description, zodSchema, fn) {
557
1059
  const result = await fn(parseResult.data, context);
558
1060
  return typeof result === "string" ? result : JSON.stringify(result);
559
1061
  };
560
- return new FunctionTool(schema, wrappedFn);
1062
+ return new _robota_sdk_agent_core.FunctionTool(schema, wrappedFn);
1063
+ }
1064
+ //#endregion
1065
+ //#region src/tool-permission-profiles.ts
1066
+ /**
1067
+ * What the permission system is told about the tools THIS package defines. CORE-030.
1068
+ *
1069
+ * The classification used to live in `@robota-sdk/agent-core`'s `permission-mode.ts`, as a matrix
1070
+ * keyed on a closed union of product tool names — a vendor-neutral foundation holding a product's
1071
+ * tool inventory, two layers below the code that defines it, with nothing coupling the two lists.
1072
+ * They drifted: `CodebaseRetrieval` is defined here and the matrix had never heard of it, so a
1073
+ * read-only retrieval prompted on every call and was refused outright in plan mode.
1074
+ *
1075
+ * A tool's own package declares what it does. The foundation decides what each MODE does about that
1076
+ * kind of action, and neither restates the other's half.
1077
+ *
1078
+ * `packages/agent-tools/src/__tests__/tool-permission-profiles.test.ts` asserts that every tool this
1079
+ * package produces appears here, so adding a tool without classifying it fails rather than silently
1080
+ * inheriting the prompt-on-every-call fallback.
1081
+ */
1082
+ /**
1083
+ * Every tool this package defines, and what the permission system needs to know about it.
1084
+ *
1085
+ * `argument.key` is which argument a pattern like `Read(/src/**)` is matched against, and
1086
+ * `argument.kind` how (CORE-049: a URL is parsed, a path is segment-wise, a command is a glob). A tool without
1087
+ * one cannot be narrowed by an argument pattern at all — the gate treats such a pattern as
1088
+ * unevaluable and prompts rather than proceeding, which is why the ones that CAN be narrowed say so.
1089
+ */
1090
+ const AGENT_TOOL_PERMISSION_PROFILES = {
1091
+ Read: {
1092
+ argument: {
1093
+ key: "filePath",
1094
+ kind: "path"
1095
+ },
1096
+ riskClass: "inspect"
1097
+ },
1098
+ Glob: {
1099
+ argument: {
1100
+ key: "pattern",
1101
+ kind: "text"
1102
+ },
1103
+ riskClass: "inspect"
1104
+ },
1105
+ Grep: {
1106
+ argument: {
1107
+ key: "pattern",
1108
+ kind: "text"
1109
+ },
1110
+ riskClass: "inspect"
1111
+ },
1112
+ WebFetch: {
1113
+ argument: {
1114
+ key: "url",
1115
+ kind: "url"
1116
+ },
1117
+ riskClass: "inspect"
1118
+ },
1119
+ WebSearch: {
1120
+ argument: {
1121
+ key: "query",
1122
+ kind: "text"
1123
+ },
1124
+ riskClass: "inspect"
1125
+ },
1126
+ CodebaseRetrieval: { riskClass: "inspect" },
1127
+ AskUserQuestion: { riskClass: "inspect" },
1128
+ ToolSearch: {
1129
+ argument: {
1130
+ key: "query",
1131
+ kind: "text"
1132
+ },
1133
+ riskClass: "inspect"
1134
+ },
1135
+ ComputerView: { riskClass: "inspect" },
1136
+ Write: {
1137
+ argument: {
1138
+ key: "filePath",
1139
+ kind: "path"
1140
+ },
1141
+ riskClass: "modify"
1142
+ },
1143
+ Edit: {
1144
+ argument: {
1145
+ key: "filePath",
1146
+ kind: "path"
1147
+ },
1148
+ riskClass: "modify"
1149
+ },
1150
+ Shell: {
1151
+ argument: {
1152
+ key: "command",
1153
+ kind: "command"
1154
+ },
1155
+ riskClass: "execute",
1156
+ aliases: ["Bash"]
1157
+ },
1158
+ Bash: {
1159
+ argument: {
1160
+ key: "command",
1161
+ kind: "command"
1162
+ },
1163
+ riskClass: "execute",
1164
+ aliases: ["Shell"]
1165
+ },
1166
+ Computer: { riskClass: "execute" }
1167
+ };
1168
+ /**
1169
+ * Tell the permission system about every tool this package defines. Idempotent.
1170
+ *
1171
+ * Not exported: the one caller is the line below. A registration a consumer could choose to skip is
1172
+ * a registration that might not happen, which is the state this change exists to leave behind.
1173
+ */
1174
+ function registerAgentToolPermissionProfiles() {
1175
+ for (const [toolName, profile] of Object.entries(AGENT_TOOL_PERMISSION_PROFILES)) (0, _robota_sdk_agent_core.registerToolPermissionProfile)(toolName, profile);
1176
+ }
1177
+ registerAgentToolPermissionProfiles();
1178
+ //#endregion
1179
+ //#region src/retrieval/retrieval-tool.ts
1180
+ /**
1181
+ * SELFHOST-003: the `CodebaseRetrieval` tool — mirrors the `create*Tool(options)` pattern.
1182
+ *
1183
+ * Composes over the injected `IRetrievalAdapter` (via `IRetrievalToolOptions`). It carries NO corpus and
1184
+ * NO domain content itself — the adapter (built from a surface-supplied parser + corpus) does the
1185
+ * ranking. With no adapter the tool reports unavailability (it is added to the default set only when an
1186
+ * adapter is present — see `createDefaultTools`).
1187
+ */
1188
+ /** Default token budget when the caller does not specify one. */
1189
+ const DEFAULT_TOKEN_BUDGET = 1e3;
1190
+ const RetrievalSchema = zod.z.object({
1191
+ activeFiles: zod.z.array(zod.z.string()).optional().describe("Repo-relative files currently in focus; the map is ranked toward what they reference."),
1192
+ mentionedIdentifiers: zod.z.array(zod.z.string()).optional().describe("Symbol names to bias the map toward (e.g. identifiers named in the task)."),
1193
+ tokenBudget: zod.z.number().int().positive().optional().describe(`Maximum tokens for the returned map (default ${DEFAULT_TOKEN_BUDGET}).`)
1194
+ });
1195
+ /** Render the ranked symbols as a compact, deterministic repo map. */
1196
+ function formatRepoMap(symbols) {
1197
+ return symbols.map((symbol) => `${symbol.file}:${symbol.line} ${symbol.kind} ${symbol.name}`).join("\n");
1198
+ }
1199
+ async function retrievalTool(args, options = {}) {
1200
+ if (!options.adapter) return "Codebase retrieval is not available in this session.";
1201
+ const result = await options.adapter.retrieve({
1202
+ ...args.activeFiles ? { activeFiles: args.activeFiles } : {},
1203
+ ...args.mentionedIdentifiers ? { mentionedIdentifiers: args.mentionedIdentifiers } : {},
1204
+ tokenBudget: args.tokenBudget ?? DEFAULT_TOKEN_BUDGET
1205
+ });
1206
+ if (result.symbols.length === 0) return "No relevant symbols found within the token budget.";
1207
+ return `Most relevant symbols (~${result.totalTokens} tokens):\n${formatRepoMap(result.symbols)}`;
1208
+ }
1209
+ function createRetrievalTool(options = {}) {
1210
+ return createZodFunctionTool("CodebaseRetrieval", "Retrieve the most relevant slice of the codebase (a ranked repo map of symbols) for the current task, within a token budget. Provide the files you are focused on and/or identifiers named in the task; returns the highest-centrality definitions first.", RetrievalSchema, async (params) => retrievalTool(params, options));
1211
+ }
1212
+ //#endregion
1213
+ //#region src/computer-use/computer-tool.ts
1214
+ /**
1215
+ * SELFHOST-010: the `ComputerView` (perceive) + `Computer` (act) tools — mirror the `create*Tool(options)`
1216
+ * pattern, split along the permission boundary.
1217
+ *
1218
+ * `createComputerTool({ driver })` registers BOTH tool names over one injected `IComputerDriver`. The split
1219
+ * is purely the permission-bearing boundary (the repo's own `Read`(auto)-vs-`Shell`(approve) precedent):
1220
+ * - `ComputerView` calls `driver.screenshot()` — a perceive with no action argument (gated `auto` like `Read`).
1221
+ * - `Computer` takes a single typed mutating `action`, executes it via `driver.act()`, and returns the
1222
+ * resulting screenshot so the model re-perceives (gated `approve`/`deny` like `Shell`).
1223
+ *
1224
+ * The typed action union stays WHOLE in the driver contract (`./types.ts`); this file only maps the
1225
+ * tool-boundary argument onto it. With no driver the tools report unavailability — they are added to the
1226
+ * default set ONLY when a driver is present (adapter-gated; there is NO host fallback — see
1227
+ * `createDefaultTools`).
1228
+ */
1229
+ const UNAVAILABLE_MESSAGE = "Computer use is not available in this session (no driver injected).";
1230
+ const MouseButtonSchema = zod.z.enum([
1231
+ "left",
1232
+ "right",
1233
+ "middle"
1234
+ ]);
1235
+ const PointSchema = zod.z.object({
1236
+ x: zod.z.number(),
1237
+ y: zod.z.number()
1238
+ });
1239
+ /**
1240
+ * The `Computer` action argument. A flat object (not a discriminated union) so it converts to JSON schema
1241
+ * — `type` selects the action and the remaining fields are validated per type in {@link buildAction}. The
1242
+ * strongly-typed discriminated union lives in the driver contract (`TComputerAction`).
1243
+ */
1244
+ const ActionSchema = zod.z.object({
1245
+ type: zod.z.enum([
1246
+ "click",
1247
+ "double_click",
1248
+ "type",
1249
+ "keypress",
1250
+ "scroll",
1251
+ "drag",
1252
+ "wait",
1253
+ "takeover"
1254
+ ]).describe("Which action to perform."),
1255
+ x: zod.z.number().optional().describe("X coordinate (click/double_click/scroll)."),
1256
+ y: zod.z.number().optional().describe("Y coordinate (click/double_click/scroll)."),
1257
+ button: MouseButtonSchema.optional().describe("Mouse button (click/double_click/drag)."),
1258
+ text: zod.z.string().optional().describe("Text to type (type)."),
1259
+ keys: zod.z.array(zod.z.string()).optional().describe("Keys to press as a chord (keypress)."),
1260
+ deltaX: zod.z.number().optional().describe("Horizontal wheel delta (scroll)."),
1261
+ deltaY: zod.z.number().optional().describe("Vertical wheel delta (scroll)."),
1262
+ path: zod.z.array(PointSchema).optional().describe("Points to drag through (drag)."),
1263
+ ms: zod.z.number().optional().describe("Milliseconds to wait (wait)."),
1264
+ reason: zod.z.string().optional().describe("Human-readable reason surfaced to the user (takeover).")
1265
+ });
1266
+ const ComputerSchema = zod.z.object({ action: ActionSchema.describe("The single mutating action to perform.") });
1267
+ const ComputerViewSchema = zod.z.object({});
1268
+ /** Raised when the tool-boundary action argument is missing fields the action type requires. */
1269
+ var InvalidComputerActionError = class extends Error {};
1270
+ function requireNumber(value, field, type) {
1271
+ if (typeof value !== "number") throw new InvalidComputerActionError(`Action '${type}' requires numeric '${field}'.`);
1272
+ return value;
1273
+ }
1274
+ /** Map the flat tool-boundary argument onto the strongly-typed driver action union. */
1275
+ function buildAction(args) {
1276
+ const button = args.button;
1277
+ switch (args.type) {
1278
+ case "click": return {
1279
+ type: "click",
1280
+ x: requireNumber(args.x, "x", "click"),
1281
+ y: requireNumber(args.y, "y", "click"),
1282
+ ...button ? { button } : {}
1283
+ };
1284
+ case "double_click": return {
1285
+ type: "double_click",
1286
+ x: requireNumber(args.x, "x", "double_click"),
1287
+ y: requireNumber(args.y, "y", "double_click"),
1288
+ ...button ? { button } : {}
1289
+ };
1290
+ case "type":
1291
+ if (typeof args.text !== "string") throw new InvalidComputerActionError("Action 'type' requires 'text'.");
1292
+ return {
1293
+ type: "type",
1294
+ text: args.text
1295
+ };
1296
+ case "keypress":
1297
+ if (!args.keys || args.keys.length === 0) throw new InvalidComputerActionError("Action 'keypress' requires non-empty 'keys'.");
1298
+ return {
1299
+ type: "keypress",
1300
+ keys: args.keys
1301
+ };
1302
+ case "scroll": return {
1303
+ type: "scroll",
1304
+ x: requireNumber(args.x, "x", "scroll"),
1305
+ y: requireNumber(args.y, "y", "scroll"),
1306
+ deltaX: requireNumber(args.deltaX, "deltaX", "scroll"),
1307
+ deltaY: requireNumber(args.deltaY, "deltaY", "scroll")
1308
+ };
1309
+ case "drag":
1310
+ if (!args.path || args.path.length < 2) throw new InvalidComputerActionError("Action 'drag' requires a 'path' of at least two points.");
1311
+ return {
1312
+ type: "drag",
1313
+ path: args.path,
1314
+ ...button ? { button } : {}
1315
+ };
1316
+ case "wait": return {
1317
+ type: "wait",
1318
+ ...typeof args.ms === "number" ? { ms: args.ms } : {}
1319
+ };
1320
+ case "takeover": return {
1321
+ type: "takeover",
1322
+ ...args.reason ? { reason: args.reason } : {}
1323
+ };
1324
+ default: {
1325
+ const exhaustive = args.type;
1326
+ throw new InvalidComputerActionError(`Unknown action type: ${String(exhaustive)}`);
1327
+ }
1328
+ }
1329
+ }
1330
+ /** `ComputerView` — perceive the current surface (returns a screenshot). Gated `auto` like `Read`. */
1331
+ async function perceive(options) {
1332
+ if (!options.driver) return JSON.stringify({
1333
+ success: false,
1334
+ error: UNAVAILABLE_MESSAGE
1335
+ });
1336
+ const screenshot = await options.driver.screenshot();
1337
+ return JSON.stringify(screenshot ? {
1338
+ success: true,
1339
+ screenshot
1340
+ } : {
1341
+ success: true,
1342
+ takeover: true
1343
+ });
1344
+ }
1345
+ /** `Computer` — execute one typed mutating action and return the resulting screenshot. Gated like `Shell`. */
1346
+ async function act(args, options) {
1347
+ if (!options.driver) return JSON.stringify({
1348
+ success: false,
1349
+ error: UNAVAILABLE_MESSAGE
1350
+ });
1351
+ let action;
1352
+ try {
1353
+ action = buildAction(args.action);
1354
+ } catch (err) {
1355
+ return JSON.stringify({
1356
+ success: false,
1357
+ error: err instanceof Error ? err.message : String(err)
1358
+ });
1359
+ }
1360
+ const outcome = await options.driver.act(action);
1361
+ const result = {
1362
+ success: true,
1363
+ ...outcome.screenshot ? { screenshot: outcome.screenshot } : {},
1364
+ ...outcome.takeover ? { takeover: true } : {}
1365
+ };
1366
+ return JSON.stringify(result);
1367
+ }
1368
+ /** Build the `ComputerView` perceive tool over the injected driver. */
1369
+ function createComputerViewTool(options = {}) {
1370
+ return createZodFunctionTool("ComputerView", "Perceive the computer/browser surface: capture and return a screenshot of the current screen so you can reason about what to do next. Read-only — it never changes anything.", ComputerViewSchema, async () => perceive(options));
1371
+ }
1372
+ /** Build the `Computer` act tool over the injected driver. */
1373
+ function createComputerActTool(options = {}) {
1374
+ return createZodFunctionTool("Computer", "Perform one mutating action on the computer/browser surface (click, double_click, type, keypress, scroll, drag, wait, or takeover) and return the resulting screenshot. Use `takeover` to hand control to the human for sensitive input (credentials/payment); perception is paused during a takeover.", ComputerSchema, async (params) => act(params, options));
1375
+ }
1376
+ /**
1377
+ * Create BOTH computer-use tools — `ComputerView` (perceive) and `Computer` (act) — over one injected
1378
+ * driver. Mirrors `create*Tool(options)`; returns the pair so the assembly layer can spread them into the
1379
+ * default set adapter-gated (see `createDefaultTools`).
1380
+ */
1381
+ function createComputerTool(options = {}) {
1382
+ return [createComputerViewTool(options), createComputerActTool(options)];
1383
+ }
1384
+ //#endregion
1385
+ //#region src/computer-use/page-computer-driver.ts
1386
+ const DEFAULT_MEDIA_TYPE = "image/png";
1387
+ const DEFAULT_WAIT_MS = 500;
1388
+ /** Encode raw screenshot bytes to base64 (accepts the page's `Uint8Array` or an already-encoded string). */
1389
+ function encodeScreenshot(bytes) {
1390
+ if (typeof bytes === "string") return bytes;
1391
+ return Buffer.from(bytes).toString("base64");
1392
+ }
1393
+ var PageComputerDriver = class {
1394
+ page;
1395
+ mediaType;
1396
+ defaultWaitMs;
1397
+ suspended = false;
1398
+ constructor(options) {
1399
+ this.page = options.page;
1400
+ this.mediaType = options.mediaType ?? DEFAULT_MEDIA_TYPE;
1401
+ this.defaultWaitMs = options.defaultWaitMs ?? DEFAULT_WAIT_MS;
1402
+ }
1403
+ async capture() {
1404
+ const type = this.mediaType === "image/jpeg" ? "jpeg" : "png";
1405
+ return {
1406
+ data: encodeScreenshot(await this.page.screenshot({ type })),
1407
+ mediaType: this.mediaType
1408
+ };
1409
+ }
1410
+ async wait(ms) {
1411
+ if (this.page.waitForTimeout) {
1412
+ await this.page.waitForTimeout(ms);
1413
+ return;
1414
+ }
1415
+ await new Promise((resolve) => setTimeout(resolve, ms));
1416
+ }
1417
+ async screenshot() {
1418
+ if (this.suspended) return;
1419
+ return this.capture();
1420
+ }
1421
+ async act(action) {
1422
+ if (action.type === "takeover") {
1423
+ await this.beginTakeover(action.reason);
1424
+ return { takeover: true };
1425
+ }
1426
+ if (this.suspended) return { takeover: true };
1427
+ const { mouse, keyboard } = this.page;
1428
+ switch (action.type) {
1429
+ case "click":
1430
+ await mouse.click(action.x, action.y, action.button ? { button: action.button } : void 0);
1431
+ break;
1432
+ case "double_click":
1433
+ await mouse.click(action.x, action.y, {
1434
+ clickCount: 2,
1435
+ ...action.button ? { button: action.button } : {}
1436
+ });
1437
+ break;
1438
+ case "type":
1439
+ await keyboard.type(action.text);
1440
+ break;
1441
+ case "keypress":
1442
+ await keyboard.press(action.keys.join("+"));
1443
+ break;
1444
+ case "scroll":
1445
+ await mouse.move(action.x, action.y);
1446
+ await mouse.wheel(action.deltaX, action.deltaY);
1447
+ break;
1448
+ case "drag":
1449
+ await this.performDrag(action);
1450
+ break;
1451
+ case "wait":
1452
+ await this.wait(action.ms ?? this.defaultWaitMs);
1453
+ break;
1454
+ }
1455
+ return { screenshot: await this.capture() };
1456
+ }
1457
+ /** Move the pointer along a multi-point path with the button held (mouse down → moves → up). */
1458
+ async performDrag(action) {
1459
+ if (action.path.length < 2) throw new Error("computer drag requires a path of at least 2 points (start + end)");
1460
+ const { mouse } = this.page;
1461
+ const [first, ...rest] = action.path;
1462
+ const button = action.button ? { button: action.button } : void 0;
1463
+ await mouse.move(first.x, first.y);
1464
+ await mouse.down(button);
1465
+ for (const point of rest) await mouse.move(point.x, point.y);
1466
+ await mouse.up(button);
1467
+ }
1468
+ async beginTakeover(_reason) {
1469
+ this.suspended = true;
1470
+ }
1471
+ async endTakeover() {
1472
+ this.suspended = false;
1473
+ }
1474
+ };
1475
+ //#endregion
1476
+ //#region src/builtins/shell-tool-description.ts
1477
+ /**
1478
+ * Dedicated-tool routing hints, keyed by the sibling tool's registered name. A hint is only
1479
+ * emitted when that sibling is actually part of the registered tool set (NEUT-002) — the
1480
+ * description must not route the model to tools that do not exist in a given assembly.
1481
+ */
1482
+ const SIBLING_ROUTING_HINTS = [
1483
+ {
1484
+ toolName: "Glob",
1485
+ hint: " - File search: Use Glob (NOT find or ls)"
1486
+ },
1487
+ {
1488
+ toolName: "Grep",
1489
+ hint: " - Content search: Use Grep (NOT grep or rg)"
1490
+ },
1491
+ {
1492
+ toolName: "Read",
1493
+ hint: " - Read files: Use Read (NOT cat/head/tail)"
1494
+ },
1495
+ {
1496
+ toolName: "Edit",
1497
+ hint: " - Edit files: Use Edit (NOT sed/awk)"
1498
+ }
1499
+ ];
1500
+ /**
1501
+ * Build the OS-aware tool description so the model writes syntax the host shell can run.
1502
+ * When `availableTools` is provided, sibling routing hints are restricted to tools in that set;
1503
+ * when omitted, the full default hint set is included (default assembly registers all siblings).
1504
+ */
1505
+ function buildShellToolDescription(shell, availableTools) {
1506
+ const hints = availableTools ? SIBLING_ROUTING_HINTS.filter((entry) => availableTools.includes(entry.toolName)) : SIBLING_ROUTING_HINTS;
1507
+ const routingBlock = hints.length > 0 ? [`IMPORTANT: Avoid using this tool to run \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands. Instead, use the appropriate dedicated tool:`, ...hints.map((entry) => entry.hint)] : [];
1508
+ return [
1509
+ `Executes a command in the host shell and returns its output.`,
1510
+ ``,
1511
+ `Active shell: ${shell.label}. ${shell.syntaxHint}`,
1512
+ ``,
1513
+ `Each command runs in a fresh shell in workingDirectory (default: the configured working directory); no shell state carries over between calls.`,
1514
+ ``,
1515
+ ...routingBlock
1516
+ ].join("\n");
561
1517
  }
562
1518
  //#endregion
563
1519
  //#region src/builtins/shell-tool.ts
@@ -570,35 +1526,36 @@ function createZodFunctionTool(name, description, zodSchema, fn) {
570
1526
  *
571
1527
  * Returns an IToolInvocationResult JSON string. A non-zero exit is returned as success:true with
572
1528
  * exitCode set (the command ran, it just exited non-zero — the LLM decides what to do with that).
1529
+ *
1530
+ * ## SEC-007 — why `workingDirectory` is NOT path-contained (a deliberate decision, not an omission)
1531
+ *
1532
+ * `Read`/`Write`/`Edit` are contained by `checkPathWithinCwd`, and SEC-007 extended that to `Glob`
1533
+ * and `Grep`. This tool is deliberately excluded, and the reason is what the tool IS: it runs an
1534
+ * arbitrary command in a shell. A guard on `cwd` is undone by the first `cd ..` — or by an absolute
1535
+ * path in the command itself — so it would constrain nothing an attacker-controlled command cannot
1536
+ * trivially step around, while LOOKING like a boundary in the code and in review.
1537
+ *
1538
+ * That appearance is the actual hazard. SEC-006's R9 lesson was "'the guard is still there' is not a
1539
+ * verdict": a check that reads as containment but is not one is worse than no check, because the next
1540
+ * reviewer stops asking. The real boundary for this tool is the permission layer (every invocation is
1541
+ * permission-gated at call time) and the sandbox seam below — which is why SEC-006 already recorded
1542
+ * `js/indirect-command-line-injection` at the spawn site as a false positive on those same grounds.
1543
+ *
1544
+ * What the containment root DOES do here: it supplies the DEFAULT working directory. Binding a tool
1545
+ * to a session root and then silently running its commands in `process.cwd()` was a real defect — an
1546
+ * assembly that scoped its file tools to a workspace still ran `Shell` wherever the host process
1547
+ * happened to be started.
573
1548
  */
574
1549
  /** POSIX children are spawned detached so a process-group kill reaps grandchildren (CORE-023). */
575
1550
  const SPAWN_DETACHED = process.platform !== "win32";
576
1551
  const DEFAULT_TIMEOUT_MS$2 = 12e4;
1552
+ /** ARCH-056: most bytes retained per stream while the child runs (head); the rest is dropped. */
1553
+ const MAX_CAPTURED_OUTPUT_BYTES = 2e6;
577
1554
  const ShellSchema = zod.z.object({
578
1555
  command: zod.z.string().describe("The shell command to execute"),
579
1556
  timeout: zod.z.number().optional().describe("Optional timeout in milliseconds (max 600000). Default is 120000 (2 minutes)"),
580
1557
  workingDirectory: zod.z.string().optional().describe("Working directory for the command. Defaults to the current working directory")
581
1558
  });
582
- /** Build the OS-aware tool description so the model writes syntax the host shell can run. */
583
- function buildShellToolDescription(shell) {
584
- return [
585
- `Executes a command in the host shell and returns its output.`,
586
- ``,
587
- `Active shell: ${shell.label}. ${shell.syntaxHint}`,
588
- ``,
589
- `The working directory persists between commands, but shell state does not.`,
590
- ``,
591
- `IMPORTANT: Avoid using this tool to run \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands. Instead, use the appropriate dedicated tool:`,
592
- ` - File search: Use Glob (NOT find or ls)`,
593
- ` - Content search: Use Grep (NOT grep or rg)`,
594
- ` - Read files: Use Read (NOT cat/head/tail)`,
595
- ` - Edit files: Use Edit (NOT sed/awk)`,
596
- ``,
597
- `For simple commands, keep the description brief (5-10 words). For complex commands, include enough context to clarify what the command does.`,
598
- ``,
599
- `Output is limited to 30,000 characters. Longer output will be middle-truncated.`
600
- ].join("\n");
601
- }
602
1559
  /** Run a shell command through the sandbox client, surfacing failures as a structured result. */
603
1560
  async function runInSandbox(command, timeout, workingDirectory, options) {
604
1561
  try {
@@ -625,44 +1582,86 @@ async function runInSandbox(command, timeout, workingDirectory, options) {
625
1582
  * Run a shell command and return stdout + stderr.
626
1583
  * Resolves with the IToolInvocationResult JSON string.
627
1584
  */
628
- async function runShell(args, options = {}, signal) {
1585
+ async function runShell(args, options, shell, signal, traceEnv) {
629
1586
  const { command, timeout: rawTimeout = DEFAULT_TIMEOUT_MS$2, workingDirectory } = args;
630
1587
  const timeout = Math.min(rawTimeout, 6e5);
631
- if (options.sandboxClient) return runInSandbox(command, timeout, workingDirectory, options);
632
- const shell = (0, _robota_sdk_agent_core.resolvePlatformShell)();
633
- if (signal?.aborted) return JSON.stringify({
1588
+ const effectiveCwd = workingDirectory ?? options.cwd;
1589
+ if (effectiveCwd === void 0) return JSON.stringify({
634
1590
  success: false,
635
1591
  output: "",
636
- error: "Aborted before start"
1592
+ error: "Shell tool has no working directory: it was constructed without a `cwd` (ARCH-010). This is an assembly bug — the tool would otherwise run in whatever directory the host process was started in."
637
1593
  });
1594
+ if (options.sandboxClient && options.sandboxClient.wrapCommand === void 0) return runInSandbox(command, timeout, workingDirectory ?? options.cwd, options);
1595
+ const hostInvocation = {
1596
+ command: shell.command,
1597
+ args: shell.commandArgs(command),
1598
+ cwd: effectiveCwd
1599
+ };
1600
+ const invocation = options.sandboxClient?.wrapCommand?.(hostInvocation, command) ?? hostInvocation;
1601
+ let released = false;
1602
+ const release = () => {
1603
+ if (released) return void 0;
1604
+ released = true;
1605
+ try {
1606
+ return invocation.afterExit?.();
1607
+ } catch (error) {
1608
+ return `[sandbox] clean-up failed: ${error instanceof Error ? error.message : String(error)}`;
1609
+ }
1610
+ };
1611
+ if (signal?.aborted) {
1612
+ release();
1613
+ return JSON.stringify({
1614
+ success: false,
1615
+ output: "",
1616
+ error: "Aborted before start"
1617
+ });
1618
+ }
638
1619
  return new Promise((resolve) => {
639
- const stdoutChunks = [];
640
- const stderrChunks = [];
1620
+ const stdoutOutput = (0, _robota_sdk_agent_core.createBoundedOutput)({ maxBytes: MAX_CAPTURED_OUTPUT_BYTES });
1621
+ const stderrOutput = (0, _robota_sdk_agent_core.createBoundedOutput)({ maxBytes: MAX_CAPTURED_OUTPUT_BYTES });
641
1622
  let timedOut = false;
642
1623
  let settled = false;
643
- const child = (0, node_child_process.spawn)(shell.command, shell.commandArgs(command), {
644
- cwd: workingDirectory ?? process.cwd(),
645
- env: process.env,
646
- stdio: [
647
- "pipe",
648
- "pipe",
649
- "pipe"
650
- ],
651
- detached: SPAWN_DETACHED
1624
+ let child;
1625
+ try {
1626
+ child = (0, node_child_process.spawn)(invocation.command, [...invocation.args], {
1627
+ cwd: invocation.cwd,
1628
+ env: traceEnv === void 0 ? process.env : (0, _robota_sdk_agent_core.subprocessTraceEnvironment)(process.env, traceEnv),
1629
+ stdio: [
1630
+ "pipe",
1631
+ "pipe",
1632
+ "pipe",
1633
+ ...(invocation.inputDescriptors ?? []).map(() => "pipe")
1634
+ ],
1635
+ detached: SPAWN_DETACHED
1636
+ });
1637
+ } catch (error) {
1638
+ const note = release();
1639
+ const message = error instanceof Error ? error.message : String(error);
1640
+ resolve(JSON.stringify({
1641
+ success: false,
1642
+ output: note ?? "",
1643
+ error: message
1644
+ }));
1645
+ return;
1646
+ }
1647
+ (invocation.inputDescriptors ?? []).forEach((data, index) => {
1648
+ const stream = child.stdio[index + 3];
1649
+ stream?.on("error", () => void 0);
1650
+ stream?.end(Buffer.from(data));
652
1651
  });
653
1652
  child.stdin?.end();
654
- child.stdout.on("data", (chunk) => {
655
- stdoutChunks.push(chunk);
1653
+ child.stdout?.on("data", (chunk) => {
1654
+ stdoutOutput.append(chunk);
656
1655
  });
657
- child.stderr.on("data", (chunk) => {
658
- stderrChunks.push(chunk);
1656
+ child.stderr?.on("data", (chunk) => {
1657
+ stderrOutput.append(chunk);
659
1658
  });
660
1659
  const timer = setTimeout(() => {
661
1660
  timedOut = true;
662
1661
  (0, _robota_sdk_agent_process.killProcessTree)(child, { processGroup: SPAWN_DETACHED });
663
1662
  settle({
664
1663
  success: false,
665
- output: Buffer.concat(stdoutChunks).toString("utf8"),
1664
+ output: stdoutOutput.toString(),
666
1665
  error: `Command timed out after ${timeout}ms`
667
1666
  });
668
1667
  }, timeout);
@@ -677,12 +1676,13 @@ async function runShell(args, options = {}, signal) {
677
1676
  (0, _robota_sdk_agent_process.killProcessTree)(child, { processGroup: SPAWN_DETACHED });
678
1677
  settle({
679
1678
  success: false,
680
- output: Buffer.concat(stdoutChunks).toString("utf8"),
1679
+ output: stdoutOutput.toString(),
681
1680
  error: "Aborted"
682
1681
  });
683
1682
  }
684
1683
  signal?.addEventListener("abort", onAbort, { once: true });
685
1684
  child.on("error", (err) => {
1685
+ if (child.pid === void 0) release();
686
1686
  settle({
687
1687
  success: false,
688
1688
  output: "",
@@ -690,21 +1690,23 @@ async function runShell(args, options = {}, signal) {
690
1690
  });
691
1691
  });
692
1692
  child.on("close", (code) => {
1693
+ const note = release();
693
1694
  if (timedOut) {
694
1695
  settle({
695
1696
  success: false,
696
- output: Buffer.concat(stdoutChunks).toString("utf8"),
1697
+ output: stdoutOutput.toString(),
697
1698
  error: `Command timed out after ${timeout}ms`,
698
1699
  exitCode: code ?? void 0
699
1700
  });
700
1701
  return;
701
1702
  }
702
- const stdout = Buffer.concat(stdoutChunks).toString("utf8");
703
- const stderr = Buffer.concat(stderrChunks).toString("utf8");
1703
+ const stdout = stdoutOutput.toString();
1704
+ const stderr = stderrOutput.toString();
704
1705
  const exitCode = code ?? 0;
1706
+ const combined = stderr ? `${stdout}\nstderr:\n${stderr}` : stdout;
705
1707
  settle({
706
1708
  success: true,
707
- output: stderr ? `${stdout}\nstderr:\n${stderr}` : stdout,
1709
+ output: note === void 0 ? combined : `${combined}\n${note}`,
708
1710
  exitCode
709
1711
  });
710
1712
  });
@@ -717,38 +1719,96 @@ async function runShell(args, options = {}, signal) {
717
1719
  * model writes the right syntax regardless of which alias it calls.
718
1720
  */
719
1721
  function createHostShellTool(name, options) {
720
- return createZodFunctionTool(name, buildShellToolDescription((0, _robota_sdk_agent_core.resolvePlatformShell)()), ShellSchema, async (params, context) => {
721
- return runShell(params, options, context?.signal);
1722
+ const shell = (0, _robota_sdk_agent_core.resolvePlatformShell)({ executable: options.shellExecutable });
1723
+ return createZodFunctionTool(name, options.description ?? buildShellToolDescription(shell, options.availableTools), ShellSchema, async (params, context) => {
1724
+ return runShell(params, options, shell, context?.signal, context?.shellTraceEnv);
722
1725
  });
723
1726
  }
724
1727
  /**
725
1728
  * Create a `Shell` tool instance — register with the Robota agent tools registry.
726
1729
  * The description is resolved at creation time for the host's active shell.
727
1730
  */
728
- function createShellTool(options = {}) {
1731
+ function createShellTool(options) {
729
1732
  return createHostShellTool("Shell", options);
730
1733
  }
731
1734
  /**
732
1735
  * Create a `Bash` tool instance — the model-familiar alias of the same OS-aware shell tool.
733
1736
  */
734
- function createBashTool(options = {}) {
1737
+ function createBashTool(options) {
735
1738
  return createHostShellTool("Bash", options);
736
1739
  }
737
- /** `Shell` tool instance — register with the Robota agent tools registry. */
738
- const shellTool = createShellTool();
739
- /** `Bash` tool instance — model-familiar alias of {@link shellTool}. */
740
- const bashTool = createBashTool();
741
1740
  //#endregion
742
1741
  //#region src/builtins/path-guard.ts
743
1742
  /**
744
- * Returns a JSON-serialized IToolInvocationResult error when filePath is outside cwd.
745
- * Returns undefined when the path is within cwd or cwd is not set.
1743
+ * Returns a JSON-serialized IToolInvocationResult error when filePath is outside cwd, or when NO
1744
+ * containment root is configured. Returns undefined only when the path is inside a configured root.
1745
+ *
1746
+ * This sentence used to end "or cwd is not set" — the fail-open default ARCH-010 removed. It sat
1747
+ * directly above the two functions that implement the distinction, which is the worst place for a
1748
+ * comment to say the opposite of the code.
1749
+ *
1750
+ * SEC-006: containment is decided on the CANONICAL (symlink-resolved) paths, via the shared
1751
+ * `isPathInside` SSOT in agent-core. A purely lexical `resolve()` + `startsWith` comparison let
1752
+ * `<cwd>/link/secret` through when `link -> /etc`, because `resolve` does not consult the filesystem
1753
+ * and so cannot see a symlink — while the subsequent `readFile`/`writeFile` followed the link out of
1754
+ * the sandbox. For `Write`/`Edit` that meant creating files anywhere the process could reach, and
1755
+ * since symlinks are ordinary committed git content, pointing the agent at an untrusted clone was
1756
+ * enough to arm it.
1757
+ *
1758
+ * The same defect existed in the CLI's monitor asset server; both now share one implementation,
1759
+ * because two containment checks that can disagree are their own defect.
746
1760
  */
1761
+ /**
1762
+ * Whether a host path is inside the tool's containment root — the single predicate every builtin
1763
+ * asks, whatever it does with the answer.
1764
+ *
1765
+ * `checkPathWithinCwd` turns a `false` into the tool-result error a tool RETURNS; the enumerating
1766
+ * tools (`Glob`, `Grep`) instead SKIP the entry mid-walk and must not fabricate an error per file.
1767
+ * Both ask this one question, which asks agent-core's `isPathInside` SSOT — so there is no second
1768
+ * containment rule that could disagree with the first (SEC-006's stated defect, SEC-007 keeping it
1769
+ * true as the guard's reach widens).
1770
+ *
1771
+ * `cwd === undefined` means no containment root is configured, and the answer is NO — ARCH-010.
1772
+ *
1773
+ * This used to return `true` there: with no root, everything was inside it. A guard whose default is
1774
+ * "allow" is not a guard, it is a guard that has to be remembered, and the architecture audit found
1775
+ * three independent layers that had forgotten. `pack-coding` had already written the consequence into
1776
+ * its own source — "file tools constructed with no options carry a DISARMED working-directory guard:
1777
+ * their `Read` will happily return `/etc/hostname`" — and the child-process subagent worker called
1778
+ * `createDefaultTools()` with no argument, so a subagent got exactly that. Measured, not inferred:
1779
+ * before this change a rootless `Read` of `/etc/hostname` returned the file.
1780
+ *
1781
+ * Refusing instead means a construction site that forgets the root fails loudly on its first file
1782
+ * access rather than silently running unconfined. The root is also required by the tool factories now,
1783
+ * so reaching this branch at all is an assembly bug — which is why the error says so specifically
1784
+ * rather than reporting an ordinary out-of-root path.
1785
+ */
1786
+ function isWithinCwd(filePath, cwd) {
1787
+ if (cwd === void 0) return false;
1788
+ return (0, _robota_sdk_agent_core_node.isPathInside)(cwd, filePath);
1789
+ }
1790
+ /**
1791
+ * Where a RELATIVE host path the model supplied is anchored: the containment root, never
1792
+ * `process.cwd()` (issue #2429). `Read`/`Write`/`Edit` declare `filePath` absolute, but nothing
1793
+ * makes the model comply, and `isPathInside` canonicalises a relative candidate against the PROCESS
1794
+ * directory — so a relative path was confined to one root and judged against another. Same rule as
1795
+ * `resolveSearchRoot` for the enumerating tools. With no root there is nothing to anchor to; the path
1796
+ * is returned as written and `checkPathWithinCwd` refuses it (ARCH-010).
1797
+ */
1798
+ function resolveHostPath(filePath, cwd) {
1799
+ if (cwd === void 0) return filePath;
1800
+ return (0, node_path.resolve)(cwd, filePath);
1801
+ }
747
1802
  function checkPathWithinCwd(filePath, cwd) {
748
- if (cwd === void 0) return void 0;
749
- const resolved = (0, node_path.resolve)(filePath);
750
- const cwdResolved = (0, node_path.resolve)(cwd);
751
- if (resolved !== cwdResolved && !resolved.startsWith(cwdResolved + node_path.sep)) {
1803
+ if (cwd === void 0) {
1804
+ const result = {
1805
+ success: false,
1806
+ output: "",
1807
+ error: `Access denied: "${filePath}" cannot be checked because no containment root is configured for this tool. This is an assembly bug, not a path problem — the tool was constructed without a \`cwd\`, so it has no boundary to enforce (ARCH-010).`
1808
+ };
1809
+ return JSON.stringify(result);
1810
+ }
1811
+ if (!isWithinCwd(filePath, cwd)) {
752
1812
  const result = {
753
1813
  success: false,
754
1814
  output: "",
@@ -757,6 +1817,28 @@ function checkPathWithinCwd(filePath, cwd) {
757
1817
  return JSON.stringify(result);
758
1818
  }
759
1819
  }
1820
+ /**
1821
+ * Resolve an LLM-supplied search root for an ENUMERATING tool, and refuse one that escapes (SEC-007).
1822
+ *
1823
+ * A relative `requested` anchors to the CONTAINMENT ROOT, not to `process.cwd()`: anchoring them to
1824
+ * two different directories is how a "contained" search silently starts somewhere else. `error`
1825
+ * carries the tool-result JSON to return, or is `undefined` when the root is allowed.
1826
+ *
1827
+ * With no root there is nothing to anchor to, so this refuses rather than reaching for the process
1828
+ * directory (ARCH-010). The previous `cwd ?? process.cwd()` was that reach: harmless once the guard
1829
+ * below refuses anyway, but it read as a supported fallback, which is the pattern being removed.
1830
+ */
1831
+ function resolveSearchRoot(requested, cwd) {
1832
+ if (cwd === void 0) return {
1833
+ root: "",
1834
+ error: checkPathWithinCwd(requested ?? "", void 0)
1835
+ };
1836
+ const root = requested ? (0, node_path.resolve)(cwd, requested) : cwd;
1837
+ return {
1838
+ root,
1839
+ error: checkPathWithinCwd(root, cwd)
1840
+ };
1841
+ }
760
1842
  //#endregion
761
1843
  //#region src/builtins/read-tool.ts
762
1844
  /**
@@ -765,7 +1847,24 @@ function checkPathWithinCwd(filePath, cwd) {
765
1847
  * Supports offset/limit for partial reads. Detects binary files and refuses to
766
1848
  * return their raw bytes. Default limit is 2000 lines.
767
1849
  */
1850
+ const DEFAULT_READ_DESCRIPTION = "Reads a file from the local filesystem.\n\nBy default, reads up to 2000 lines from the beginning of the file. You can optionally specify offset and limit for partial reads.\n\nResults are returned using cat -n format, with line numbers starting at 1.\n\nThe filePath parameter must be an absolute path, not a relative path.";
768
1851
  const DEFAULT_LIMIT$1 = 2e3;
1852
+ const MAX_READ_BYTES = 4 * 1024 * 1024;
1853
+ const READ_CHUNK_BYTES$2 = 64 * 1024;
1854
+ /** A budget refusal is a hard failure so a workflow cannot treat it as file content. */
1855
+ var ReadByteLimitError = class extends _robota_sdk_agent_core.ToolExecutionError {
1856
+ boundary;
1857
+ constructor(boundary) {
1858
+ super(`Read ${boundary} exceeds its UTF-8 byte limit`, "Read");
1859
+ this.boundary = boundary;
1860
+ }
1861
+ };
1862
+ /** Abort is a hard failure; the workflow must not accept a partial read. */
1863
+ var ReadCancelledError = class extends _robota_sdk_agent_core.ToolExecutionError {
1864
+ constructor() {
1865
+ super("Read cancelled", "Read");
1866
+ }
1867
+ };
769
1868
  const ReadSchema = zod.z.object({
770
1869
  filePath: zod.z.string().describe("The absolute path to the file to read"),
771
1870
  offset: zod.z.number().optional().describe("The line number to start reading from (1-based). Only provide if the file is too large to read at once"),
@@ -791,25 +1890,49 @@ function formatWithLineNumbers(lines, startLine) {
791
1890
  }).join("\n");
792
1891
  }
793
1892
  function formatReadResult(filePath, content, startLine, limit) {
794
- const allLines = content.split("\n");
795
- if (allLines[allLines.length - 1] === "") allLines.pop();
796
- const zeroBasedStart = startLine - 1;
797
- const selectedLines = allLines.slice(zeroBasedStart, zeroBasedStart + limit);
798
- const output = formatWithLineNumbers(selectedLines, startLine);
799
- const totalLines = allLines.length;
1893
+ const selectedLines = [];
1894
+ let selectedMinimumBytes = 0;
1895
+ let totalLines = 0;
1896
+ let lineStart = 0;
1897
+ const selectedStart = Math.trunc(startLine - 1);
1898
+ const selectedEnd = Math.trunc(startLine - 1 + limit);
1899
+ while (lineStart < content.length) {
1900
+ const newline = content.indexOf("\n", lineStart);
1901
+ const lineEnd = newline === -1 ? content.length : newline;
1902
+ totalLines++;
1903
+ if (totalLines > selectedStart && totalLines <= selectedEnd) {
1904
+ const line = content.slice(lineStart, lineEnd);
1905
+ selectedMinimumBytes += Buffer.byteLength(line, "utf8") + String(startLine + selectedLines.length).length + 1;
1906
+ if (selectedMinimumBytes > MAX_READ_BYTES) throw new ReadByteLimitError("output");
1907
+ selectedLines.push(line);
1908
+ }
1909
+ if (newline === -1) break;
1910
+ lineStart = newline + 1;
1911
+ }
800
1912
  const returnedLines = selectedLines.length;
1913
+ const header = returnedLines < totalLines ? `[File: ${filePath} (lines ${startLine}-${startLine + returnedLines - 1} of ${totalLines})]\n` : `[File: ${filePath} (${totalLines} lines)]\n`;
1914
+ const width = String(startLine + returnedLines - 1).length;
1915
+ let outputBytes = Buffer.byteLength(header, "utf8") + Math.max(0, returnedLines - 1);
1916
+ for (const line of selectedLines) outputBytes += width + 1 + Buffer.byteLength(line, "utf8");
1917
+ if (outputBytes > MAX_READ_BYTES) throw new ReadByteLimitError("output");
801
1918
  const result = {
802
1919
  success: true,
803
- output: (returnedLines < totalLines ? `[File: ${filePath} (lines ${startLine}-${startLine + returnedLines - 1} of ${totalLines})]\n` : `[File: ${filePath} (${totalLines} lines)]\n`) + output
1920
+ output: header + formatWithLineNumbers(selectedLines, startLine)
804
1921
  };
805
1922
  return JSON.stringify(result);
806
1923
  }
807
- async function readFileTool(args, options = {}) {
808
- const { filePath, offset, limit = DEFAULT_LIMIT$1 } = args;
1924
+ async function readFileTool(args, options) {
1925
+ if (options.signal?.aborted) throw new ReadCancelledError();
1926
+ const { offset, limit = DEFAULT_LIMIT$1 } = args;
1927
+ const filePath = options.sandboxClient ? args.filePath : resolveHostPath(args.filePath, options.cwd);
809
1928
  const startLine = offset !== void 0 && offset > 0 ? offset : 1;
810
1929
  if (options.sandboxClient) try {
811
- return formatReadResult(filePath, await options.sandboxClient.readFile(filePath), startLine, limit);
1930
+ const content = await options.sandboxClient.readFile(filePath);
1931
+ if (options.signal?.aborted) throw new ReadCancelledError();
1932
+ if (Buffer.byteLength(content, "utf8") > MAX_READ_BYTES) throw new ReadByteLimitError("input");
1933
+ return formatReadResult(filePath, content, startLine, limit);
812
1934
  } catch (err) {
1935
+ if (err instanceof ReadByteLimitError || err instanceof ReadCancelledError) throw err;
813
1936
  const result = {
814
1937
  success: false,
815
1938
  output: "",
@@ -838,10 +1961,35 @@ async function readFileTool(args, options = {}) {
838
1961
  };
839
1962
  return JSON.stringify(result);
840
1963
  }
841
- let buffer;
1964
+ let buffer = Buffer.alloc(0);
1965
+ let binaryFile = false;
842
1966
  try {
843
- buffer = await (0, node_fs_promises.readFile)(filePath);
1967
+ const handle = await (0, node_fs_promises.open)(filePath, "r");
1968
+ try {
1969
+ const chunks = [];
1970
+ const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES$2);
1971
+ let bytes = 0;
1972
+ let binaryCheckedBytes = 0;
1973
+ while (bytes <= MAX_READ_BYTES) {
1974
+ if (options.signal?.aborted) throw new ReadCancelledError();
1975
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, 4194305 - bytes), null);
1976
+ if (bytesRead === 0) break;
1977
+ const binaryCheckLength = Math.min(bytesRead, 8192 - binaryCheckedBytes);
1978
+ if (binaryCheckLength > 0 && isBinary(chunk.subarray(0, binaryCheckLength))) {
1979
+ binaryFile = true;
1980
+ break;
1981
+ }
1982
+ binaryCheckedBytes += binaryCheckLength;
1983
+ bytes += bytesRead;
1984
+ if (bytes > MAX_READ_BYTES) throw new ReadByteLimitError("input");
1985
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
1986
+ }
1987
+ if (!binaryFile) buffer = Buffer.concat(chunks, bytes);
1988
+ } finally {
1989
+ await handle.close();
1990
+ }
844
1991
  } catch (err) {
1992
+ if (err instanceof ReadByteLimitError || err instanceof ReadCancelledError) throw err;
845
1993
  const result = {
846
1994
  success: false,
847
1995
  output: "",
@@ -849,7 +1997,8 @@ async function readFileTool(args, options = {}) {
849
1997
  };
850
1998
  return JSON.stringify(result);
851
1999
  }
852
- if (isBinary(buffer)) {
2000
+ if (options.signal?.aborted) throw new ReadCancelledError();
2001
+ if (binaryFile) {
853
2002
  const result = {
854
2003
  success: false,
855
2004
  output: "",
@@ -862,25 +2011,30 @@ async function readFileTool(args, options = {}) {
862
2011
  /**
863
2012
  * Create a ReadTool instance — register with Robota agent tools registry.
864
2013
  */
865
- function createReadTool(options = {}) {
866
- return createZodFunctionTool("Read", "Reads a file from the local filesystem.\n\nBy default, reads up to 2000 lines from the beginning of the file. You can optionally specify offset and limit for partial reads.\n\nResults are returned using cat -n format, with line numbers starting at 1.\n\nThe file_path parameter must be an absolute path, not a relative path.", ReadSchema, async (params) => {
2014
+ function createReadTool(options) {
2015
+ return createZodFunctionTool("Read", options.description ?? DEFAULT_READ_DESCRIPTION, ReadSchema, async (params) => {
867
2016
  return readFileTool(params, options);
868
2017
  });
869
2018
  }
870
- /**
871
- * ReadTool instance — register with Robota agent tools registry.
872
- */
873
- const readTool = createReadTool();
874
2019
  //#endregion
875
2020
  //#region src/builtins/atomic-file-write.ts
876
2021
  const TEMP_RANDOM_BYTES = 6;
877
2022
  const PRESERVED_MODE_BITS = 4095;
878
2023
  const MISSING_FILE_ERROR_CODE = "ENOENT";
2024
+ /**
2025
+ * NEUT-009: this marker used to carry the consumer's product name, so a neutral tool library wrote
2026
+ * that name onto every temporary file it created — inherited by any other product built on it. The
2027
+ * marker now says what the file IS, which is all it was ever for.
2028
+ *
2029
+ * The product name is not quoted here either: the ratchet counts prose, deliberately, because a
2030
+ * library whose comments teach the product's layout is coupled to it just as firmly.
2031
+ */
2032
+ const TEMP_MARKER = ".atomic-tmp-";
879
2033
  function createTempFilePath(filePath) {
880
2034
  const dir = (0, node_path.dirname)(filePath);
881
2035
  const name = (0, node_path.basename)(filePath);
882
2036
  const suffix = (0, node_crypto.randomBytes)(TEMP_RANDOM_BYTES).toString("hex");
883
- return (0, node_path.join)(dir, `.${name}.robota-tmp-${process.pid}-${Date.now()}-${suffix}`);
2037
+ return (0, node_path.join)(dir, `.${name}${TEMP_MARKER}${process.pid}-${Date.now()}-${suffix}`);
884
2038
  }
885
2039
  async function readExistingMode(filePath) {
886
2040
  try {
@@ -911,12 +2065,14 @@ async function atomicWriteUtf8File(filePath, content) {
911
2065
  /**
912
2066
  * WriteTool — write content to a file, auto-creating parent directories.
913
2067
  */
2068
+ const DEFAULT_WRITE_DESCRIPTION = "Writes a file to the local filesystem. This will overwrite an existing file if one exists.\n\nPrefer the Edit tool for modifying existing files — it only sends the changed text. Use this tool to create new files or for complete rewrites.\n\nParent directories are created automatically when missing.";
914
2069
  const WriteSchema = zod.z.object({
915
2070
  filePath: zod.z.string().describe("The absolute path to the file to write"),
916
2071
  content: zod.z.string().describe("The content to write to the file")
917
2072
  });
918
- async function writeFileTool(args, options = {}) {
919
- const { filePath, content } = args;
2073
+ async function writeFileTool(args, options) {
2074
+ const { content } = args;
2075
+ const filePath = options.sandboxClient ? args.filePath : resolveHostPath(args.filePath, options.cwd);
920
2076
  if (!options.sandboxClient) {
921
2077
  const pathError = checkPathWithinCwd(filePath, options.cwd);
922
2078
  if (pathError !== void 0) return pathError;
@@ -941,15 +2097,11 @@ async function writeFileTool(args, options = {}) {
941
2097
  /**
942
2098
  * Create a WriteTool instance — register with Robota agent tools registry.
943
2099
  */
944
- function createWriteTool(options = {}) {
945
- return createZodFunctionTool("Write", "Writes a file to the local filesystem. This will overwrite an existing file if one exists.\n\nALWAYS prefer the Edit tool for modifying existing files — it only sends the diff. Only use this tool to create new files or for complete rewrites.\n\nNEVER create documentation files (*.md) or README files unless explicitly requested by the user.", WriteSchema, async (params) => {
2100
+ function createWriteTool(options) {
2101
+ return createZodFunctionTool("Write", options.description ?? DEFAULT_WRITE_DESCRIPTION, WriteSchema, async (params) => {
946
2102
  return writeFileTool(params, options);
947
2103
  });
948
2104
  }
949
- /**
950
- * WriteTool instance — register with Robota agent tools registry.
951
- */
952
- const writeTool = createWriteTool();
953
2105
  //#endregion
954
2106
  //#region src/builtins/edit-tool.ts
955
2107
  /**
@@ -958,22 +2110,67 @@ const writeTool = createWriteTool();
958
2110
  * By default, requires the oldString to appear exactly once in the file
959
2111
  * (ensuring surgical edits). Pass replaceAll:true to replace all occurrences.
960
2112
  */
2113
+ const DEFAULT_EDIT_DESCRIPTION = "Performs exact string replacements in files.\n\noldString must exactly match the file's current content, including whitespace and indentation — reading the file first (e.g. with a file-read tool) is the reliable way to copy exact text.\n\nThe edit will FAIL if oldString is not unique in the file. Either provide more surrounding context to make it unique, or set replaceAll to change every instance.";
961
2114
  const EditSchema = zod.z.object({
962
2115
  filePath: zod.z.string().describe("The absolute path to the file to modify"),
963
2116
  oldString: zod.z.string().describe("The text to replace (must be an exact match of existing content)"),
964
- newString: zod.z.string().describe("The text to replace it with (must be different from old_string)"),
965
- replaceAll: zod.z.boolean().optional().describe("Replace all occurrences of old_string (default: false). Useful for renaming variables")
2117
+ newString: zod.z.string().describe("The text to replace it with (must be different from oldString)"),
2118
+ replaceAll: zod.z.boolean().optional().describe("Replace all occurrences of oldString (default: false). Useful for renaming variables")
966
2119
  });
967
- async function editFileTool(args, options = {}) {
968
- const { filePath, oldString, newString, replaceAll = false } = args;
2120
+ const MAX_EDIT_FILE_BYTES = 4 * 1024 * 1024;
2121
+ const READ_CHUNK_BYTES$1 = 64 * 1024;
2122
+ /** Marks a refusal that must not surface a partial or crashed read to the caller. */
2123
+ var EditByteLimitError = class extends Error {
2124
+ boundary;
2125
+ constructor(boundary) {
2126
+ super(`Edit ${boundary} exceeds its ${MAX_EDIT_FILE_BYTES}-byte limit`);
2127
+ this.boundary = boundary;
2128
+ }
2129
+ };
2130
+ /**
2131
+ * Read a file as UTF-8 while rejecting as soon as more than `maxBytes` bytes have arrived —
2132
+ * before the whole content is materialized. Reading actual bytes off the stream (rather than
2133
+ * trusting stat() size) also catches a file that grows after being stat'd, or has no stable
2134
+ * size at all (a named pipe).
2135
+ */
2136
+ async function readBoundedUtf8File(filePath, maxBytes) {
2137
+ const stream = (0, node_fs.createReadStream)(filePath, { highWaterMark: READ_CHUNK_BYTES$1 });
2138
+ const chunks = [];
2139
+ let bytes = 0;
2140
+ try {
2141
+ for await (const chunk of stream) {
2142
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2143
+ bytes += buffer.length;
2144
+ if (bytes > maxBytes) throw new EditByteLimitError("input");
2145
+ chunks.push(buffer);
2146
+ }
2147
+ } finally {
2148
+ stream.destroy();
2149
+ }
2150
+ return Buffer.concat(chunks, bytes).toString("utf8");
2151
+ }
2152
+ async function editFileTool(args, options) {
2153
+ const { oldString, newString, replaceAll = false } = args;
2154
+ const filePath = options.sandboxClient ? args.filePath : resolveHostPath(args.filePath, options.cwd);
969
2155
  if (!options.sandboxClient) {
970
2156
  const pathError = checkPathWithinCwd(filePath, options.cwd);
971
2157
  if (pathError !== void 0) return pathError;
972
2158
  }
973
2159
  let content;
974
2160
  try {
975
- content = options.sandboxClient ? await options.sandboxClient.readFile(filePath) : await (0, node_fs_promises.readFile)(filePath, "utf8");
2161
+ if (options.sandboxClient) {
2162
+ content = await options.sandboxClient.readFile(filePath);
2163
+ if (Buffer.byteLength(content, "utf8") > MAX_EDIT_FILE_BYTES) throw new EditByteLimitError("input");
2164
+ } else content = await readBoundedUtf8File(filePath, MAX_EDIT_FILE_BYTES);
976
2165
  } catch (err) {
2166
+ if (err instanceof EditByteLimitError) {
2167
+ const result = {
2168
+ success: false,
2169
+ output: "",
2170
+ error: `${err.message}: ${filePath}`
2171
+ };
2172
+ return JSON.stringify(result);
2173
+ }
977
2174
  const result = {
978
2175
  success: false,
979
2176
  output: "",
@@ -989,17 +2186,28 @@ async function editFileTool(args, options = {}) {
989
2186
  };
990
2187
  return JSON.stringify(result);
991
2188
  }
992
- if (!replaceAll) {
993
- if (content.indexOf(oldString) !== content.lastIndexOf(oldString)) {
994
- const result = {
995
- success: false,
996
- output: "",
997
- error: `oldString is not unique in file (found ${content.split(oldString).length - 1} occurrences). Provide more context to make it unique, or use replaceAll:true.`
998
- };
999
- return JSON.stringify(result);
1000
- }
2189
+ let parts = [];
2190
+ if (replaceAll) parts = content.split(oldString);
2191
+ else if (content.indexOf(oldString) !== content.lastIndexOf(oldString)) {
2192
+ const result = {
2193
+ success: false,
2194
+ output: "",
2195
+ error: `oldString is not unique in file (found ${content.split(oldString).length - 1} occurrences). Provide more context to make it unique, or use replaceAll:true.`
2196
+ };
2197
+ return JSON.stringify(result);
2198
+ }
2199
+ const count = replaceAll ? parts.length - 1 : 1;
2200
+ const oldBytes = Buffer.byteLength(oldString, "utf8");
2201
+ const newBytes = Buffer.byteLength(newString, "utf8");
2202
+ if (Buffer.byteLength(content, "utf8") - count * oldBytes + count * newBytes > MAX_EDIT_FILE_BYTES) {
2203
+ const result = {
2204
+ success: false,
2205
+ output: "",
2206
+ error: `Edit output exceeds its ${MAX_EDIT_FILE_BYTES}-byte limit: ${filePath}`
2207
+ };
2208
+ return JSON.stringify(result);
1001
2209
  }
1002
- const updated = replaceAll ? content.split(oldString).join(newString) : content.slice(0, content.indexOf(oldString)) + newString + content.slice(content.indexOf(oldString) + oldString.length);
2210
+ const updated = replaceAll ? parts.join(newString) : content.slice(0, content.indexOf(oldString)) + newString + content.slice(content.indexOf(oldString) + oldString.length);
1003
2211
  try {
1004
2212
  if (options.sandboxClient) await options.sandboxClient.writeFile(filePath, updated);
1005
2213
  else await atomicWriteUtf8File(filePath, updated);
@@ -1011,7 +2219,6 @@ async function editFileTool(args, options = {}) {
1011
2219
  };
1012
2220
  return JSON.stringify(result);
1013
2221
  }
1014
- const count = replaceAll ? content.split(oldString).length - 1 : 1;
1015
2222
  const matchIdx = content.indexOf(oldString);
1016
2223
  const startLine = matchIdx >= 0 ? content.substring(0, matchIdx).split("\n").length : 1;
1017
2224
  const result = {
@@ -1024,40 +2231,110 @@ async function editFileTool(args, options = {}) {
1024
2231
  /**
1025
2232
  * Create an EditTool instance — register with Robota agent tools registry.
1026
2233
  */
1027
- function createEditTool(options = {}) {
1028
- return createZodFunctionTool("Edit", "Performs exact string replacements in files.\n\nYou must use the Read tool at least once before editing. When editing text from Read output, preserve the exact indentation.\n\nThe edit will FAIL if old_string is not unique in the file. Either provide more surrounding context to make it unique, or use replace_all to change every instance.\n\nALWAYS prefer editing existing files over creating new ones.", EditSchema, async (params) => {
2234
+ function createEditTool(options) {
2235
+ return createZodFunctionTool("Edit", options.description ?? DEFAULT_EDIT_DESCRIPTION, EditSchema, async (params) => {
1029
2236
  return editFileTool(params, options);
1030
2237
  });
1031
2238
  }
2239
+ //#endregion
2240
+ //#region src/builtins/glob-tool.ts
2241
+ /**
2242
+ * GlobTool — fast file pattern search using fast-glob.
2243
+ *
2244
+ * Excludes node_modules and .git by default.
2245
+ * Results are sorted by modification time (most recently modified first) among the candidates
2246
+ * enumerated before any candidate ceiling was hit (see DEFAULT_MAX_GLOB_CANDIDATES) — ordering is
2247
+ * not guaranteed across the full match set when the search tree is larger than that ceiling.
2248
+ *
2249
+ * SEC-007: when a containment root is configured the enumeration is confined to it. Listing the
2250
+ * filesystem is a disclosure in its own right — a sandbox that stops the model reading a file but
2251
+ * lets it map everything around that file is not a sandbox.
2252
+ */
2253
+ const DEFAULT_MAX_RESULTS = 1e3;
2254
+ /**
2255
+ * Ceiling on how many raw glob matches are pulled off `fast-glob`'s match STREAM before enumeration
2256
+ * stops, independent of `limit`/`DEFAULT_MAX_RESULTS`.
2257
+ *
2258
+ * `fg(pattern)` (the promise form) materializes every match into memory and only then stats and
2259
+ * slices to `limit` — a pattern like `**\/*` under a huge tree allocates and stats the whole match
2260
+ * set no matter how small `limit` is. Streaming lets the walk stop as soon as this many CANDIDATES
2261
+ * have been seen, so memory and stat fan-out scale with this ceiling, not with the tree.
2262
+ */
2263
+ const DEFAULT_MAX_GLOB_CANDIDATES = 5e4;
2264
+ const GlobSchema = zod.z.object({
2265
+ pattern: zod.z.string().describe("The glob pattern to match files against (e.g. \"**/*.ts\", \"src/**/*.tsx\")"),
2266
+ path: zod.z.string().optional().describe("The directory to search in. Defaults to the current working directory. Must be a valid directory path if provided"),
2267
+ limit: zod.z.number().optional().describe("Maximum number of results to return (default: 1000). Use a smaller limit to save context space")
2268
+ });
2269
+ /** Cap on concurrent `stat` calls during the mtime sort, so a large match set cannot storm the FS. */
2270
+ const STAT_CONCURRENCY_LIMIT = 100;
2271
+ /**
2272
+ * Drop every match whose CANONICAL path escapes the containment root, then stat the survivors for the
2273
+ * mtime sort, newest first.
2274
+ *
2275
+ * Containment is decided per RESULT as well as per root (SEC-007): a `..` in the pattern, or an
2276
+ * absolute pattern, produces a match the search root never vouched for. Decided canonically through
2277
+ * the shared guard — a symlink named `escape` is a plain segment, so no amount of segment validation
2278
+ * would catch it.
2279
+ */
2280
+ async function containedMatchesByMtime(matches, cwd, containmentRoot) {
2281
+ const limit = (0, p_limit.default)(STAT_CONCURRENCY_LIMIT);
2282
+ return (await Promise.all(matches.map((p) => limit(async () => {
2283
+ const absPath = (0, node_path.resolve)(cwd, p);
2284
+ if (!isWithinCwd(absPath, containmentRoot)) return void 0;
2285
+ try {
2286
+ return {
2287
+ path: p,
2288
+ mtime: (await (0, node_fs_promises.stat)(absPath)).mtimeMs
2289
+ };
2290
+ } catch {
2291
+ return {
2292
+ path: p,
2293
+ mtime: 0
2294
+ };
2295
+ }
2296
+ })))).filter((entry) => entry !== void 0).sort((a, b) => b.mtime - a.mtime);
2297
+ }
1032
2298
  /**
1033
- * EditTool instance — register with Robota agent tools registry.
2299
+ * Pull matches off `fast-glob`'s streaming API one at a time, stopping at `maxCandidates` instead of
2300
+ * materializing the whole match set (see {@link DEFAULT_MAX_GLOB_CANDIDATES}). Exported for tests that
2301
+ * need a smaller ceiling than the real default.
1034
2302
  */
1035
- const editTool = createEditTool();
1036
- //#endregion
1037
- //#region src/builtins/glob-tool.ts
2303
+ async function collectGlobMatches(pattern, options, maxCandidates) {
2304
+ const matches = [];
2305
+ let truncated = false;
2306
+ const stream = fast_glob.default.stream(pattern, options);
2307
+ for await (const entry of stream) {
2308
+ if (matches.length >= maxCandidates) {
2309
+ truncated = true;
2310
+ break;
2311
+ }
2312
+ matches.push(entry);
2313
+ }
2314
+ return {
2315
+ matches,
2316
+ truncated
2317
+ };
2318
+ }
1038
2319
  /**
1039
- * GlobTool — fast file pattern search using fast-glob.
1040
- *
1041
- * Excludes node_modules and .git by default.
1042
- * Results are sorted by modification time (most recently modified first).
2320
+ * Exported (rather than module-private) so tests can drive it with a `maxCandidates` far smaller than
2321
+ * {@link DEFAULT_MAX_GLOB_CANDIDATES} — the real default is too large to exercise cheaply — without
2322
+ * adding any test-only knob to the public `createGlobTool` factory or its schema.
1043
2323
  */
1044
- const DEFAULT_MAX_RESULTS = 1e3;
1045
- const GlobSchema = zod.z.object({
1046
- pattern: zod.z.string().describe("The glob pattern to match files against (e.g. \"**/*.ts\", \"src/**/*.tsx\")"),
1047
- path: zod.z.string().optional().describe("The directory to search in. Defaults to the current working directory. Must be a valid directory path if provided"),
1048
- limit: zod.z.number().optional().describe("Maximum number of results to return (default: 1000). Use a smaller limit to save context space")
1049
- });
1050
- async function globFileTool(args) {
2324
+ async function globFileTool(args, options, maxCandidates = DEFAULT_MAX_GLOB_CANDIDATES) {
1051
2325
  const { pattern, path: basePath } = args;
1052
- const cwd = basePath ? (0, node_path.resolve)(basePath) : process.cwd();
1053
- let matches;
2326
+ const containmentRoot = options.cwd;
2327
+ const { root: cwd, error: rootError } = resolveSearchRoot(basePath, containmentRoot);
2328
+ if (rootError) return rootError;
2329
+ let candidates;
1054
2330
  try {
1055
- matches = await (0, fast_glob.default)(pattern, {
2331
+ candidates = await collectGlobMatches(pattern, {
1056
2332
  cwd,
1057
2333
  ignore: ["**/node_modules/**", "**/.git/**"],
1058
2334
  dot: true,
1059
- absolute: false
1060
- });
2335
+ absolute: false,
2336
+ followSymbolicLinks: false
2337
+ }, maxCandidates);
1061
2338
  } catch (err) {
1062
2339
  const result = {
1063
2340
  success: false,
@@ -1066,63 +2343,39 @@ async function globFileTool(args) {
1066
2343
  };
1067
2344
  return JSON.stringify(result);
1068
2345
  }
1069
- const limit = (0, p_limit.default)(100);
1070
- const withMtime = await Promise.all(matches.map((p) => limit(async () => {
1071
- const absPath = (0, node_path.resolve)(cwd, p);
1072
- try {
1073
- return {
1074
- path: p,
1075
- mtime: (await (0, node_fs_promises.stat)(absPath)).mtimeMs
1076
- };
1077
- } catch {
1078
- return {
1079
- path: p,
1080
- mtime: 0
1081
- };
1082
- }
1083
- })));
1084
- withMtime.sort((a, b) => b.mtime - a.mtime);
2346
+ const { matches, truncated: candidatesTruncated } = candidates;
2347
+ const withMtime = await containedMatchesByMtime(matches, cwd, containmentRoot);
1085
2348
  const maxResults = args.limit ?? DEFAULT_MAX_RESULTS;
1086
2349
  const totalMatches = withMtime.length;
1087
2350
  const truncated = totalMatches > maxResults;
1088
2351
  const sorted = (truncated ? withMtime.slice(0, maxResults) : withMtime).map((f) => f.path);
1089
2352
  let output = sorted.length > 0 ? sorted.join("\n") : "(no matches)";
1090
2353
  if (truncated) output += `\n\n[Showing ${maxResults} of ${totalMatches} matches. Use limit parameter to see more.]`;
2354
+ if (candidatesTruncated) output += `\n\n[Candidate search stopped early; the search tree has more matches than this tool scans in one call. Results are ordered among the scanned candidates only — narrow the pattern or path to see the rest.]`;
1091
2355
  return JSON.stringify({
1092
2356
  success: true,
1093
2357
  output
1094
2358
  });
1095
2359
  }
2360
+ const DEFAULT_GLOB_DESCRIPTION = "Fast file pattern matching tool that works with any codebase size.\n\nSupports glob patterns like '**/*.js' or 'src/**/*.ts'. Returns matching file paths sorted by modification time.\n\nUse this tool when you need to find files by name patterns.\n\nDefault limit is 1000 results. Use the limit parameter if you need fewer results to save context space.";
1096
2361
  /**
1097
- * GlobTool instance — register with Robota agent tools registry.
2362
+ * Create a GlobTool instance — register with Robota agent tools registry.
1098
2363
  */
1099
- const globTool = createZodFunctionTool("Glob", "Fast file pattern matching tool that works with any codebase size.\n\nSupports glob patterns like '**/*.js' or 'src/**/*.ts'. Returns matching file paths sorted by modification time.\n\nUse this tool when you need to find files by name patterns. When doing an open-ended search that may require multiple rounds, use the Agent tool instead.\n\nDefault limit is 1000 results. Use the limit parameter if you need fewer results to save context space.", GlobSchema, async (params) => {
1100
- return globFileTool(params);
1101
- });
2364
+ function createGlobTool(options) {
2365
+ return createZodFunctionTool("Glob", options.description ?? DEFAULT_GLOB_DESCRIPTION, GlobSchema, async (params) => {
2366
+ return globFileTool(params, options);
2367
+ });
2368
+ }
1102
2369
  //#endregion
1103
- //#region src/builtins/grep-tool.ts
2370
+ //#region src/builtins/grep-search.ts
1104
2371
  /**
1105
- * GrepTool — recursive regex content search.
1106
- *
1107
- * Supports three output modes:
1108
- * - files_with_matches (default): return only file paths that contain a match
1109
- * - content: return matching lines with optional context lines
1110
- * - count: return per-file match counts as "path:count" rows
2372
+ * The `Grep` tool's search internals — file enumeration and per-file matching.
1111
2373
  *
1112
- * headLimit caps the number of result lines; excess is truncated with a marker.
2374
+ * Split out of `grep-tool.ts` (SEC-007) when adding containment pushed that file past the
2375
+ * anti-monolith limit. The split is by responsibility, not by line count: this module is HOW the
2376
+ * search is performed, while `grep-tool.ts` is the tool SURFACE — schema, model-facing description,
2377
+ * factory, and the result envelope. Neither half needs to know the other's concerns.
1113
2378
  */
1114
- const GrepSchema = zod.z.object({
1115
- pattern: zod.z.string().describe("The regular expression pattern to search for in file contents"),
1116
- path: zod.z.string().optional().describe("File or directory to search in. Defaults to the current working directory"),
1117
- glob: zod.z.string().optional().describe("Glob pattern to filter files (e.g. \"*.ts\", \"*.{ts,tsx}\"). Only files matching this pattern will be searched"),
1118
- contextLines: zod.z.number().optional().describe("Number of context lines to show before and after each match. Only applies when outputMode is \"content\". Default: 0"),
1119
- outputMode: zod.z.enum([
1120
- "files_with_matches",
1121
- "content",
1122
- "count"
1123
- ]).optional().describe("Output mode: \"files_with_matches\" shows only file paths (default), \"content\" shows matching lines with context, \"count\" shows per-file match counts"),
1124
- headLimit: zod.z.number().int().positive().optional().describe("Maximum number of result lines (file paths, content lines, or count rows) to return. Excess results are truncated with a marker line")
1125
- });
1126
2379
  /** Convert a simple glob to a RegExp for file name filtering. */
1127
2380
  function globToRegex(glob) {
1128
2381
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".+").replace(/\*/g, "[^/]*");
@@ -1133,10 +2386,29 @@ function matchesGlob(filename, glob) {
1133
2386
  if (glob === void 0) return true;
1134
2387
  return globToRegex(glob).test(filename);
1135
2388
  }
1136
- /** Gather all files under a directory recursively, excluding node_modules/.git. */
1137
- async function collectFiles(dirPath, glob) {
2389
+ /**
2390
+ * Ceiling on how many directory entries `collectFiles` will `stat` before it stops walking.
2391
+ *
2392
+ * Without a cap, enumeration and stat fan-out scale with the whole tree under the search root,
2393
+ * not with any result limit — a directory with millions of files makes every `Grep` call walk and
2394
+ * stat millions of entries before `headLimit` ever gets a chance to truncate the OUTPUT. This bounds
2395
+ * the WALK itself.
2396
+ */
2397
+ const DEFAULT_MAX_COLLECTED_FILES = 5e4;
2398
+ /**
2399
+ * Gather files under a directory recursively, excluding node_modules/.git, stopping once `maxFiles`
2400
+ * entries have been visited.
2401
+ *
2402
+ * `containmentRoot` (SEC-007) drops any entry whose CANONICAL path escapes the root, before it is
2403
+ * descended into or read. `stat` follows symlinks, so without this a link inside the root pointing
2404
+ * out of it made the whole target tree readable — including, for a symlinked FILE, its contents.
2405
+ */
2406
+ async function collectFiles(dirPath, glob, containmentRoot, maxFiles = DEFAULT_MAX_COLLECTED_FILES) {
1138
2407
  const results = [];
2408
+ let visited = 0;
2409
+ let truncated = false;
1139
2410
  async function walk(current) {
2411
+ if (truncated) return;
1140
2412
  let entryNames;
1141
2413
  try {
1142
2414
  entryNames = await (0, node_fs_promises.readdir)(current);
@@ -1144,8 +2416,15 @@ async function collectFiles(dirPath, glob) {
1144
2416
  return;
1145
2417
  }
1146
2418
  for (const name of entryNames) {
2419
+ if (truncated) return;
1147
2420
  if (name === "node_modules" || name === ".git") continue;
1148
2421
  const fullPath = (0, node_path.join)(current, name);
2422
+ if (!isWithinCwd(fullPath, containmentRoot)) continue;
2423
+ if (visited >= maxFiles) {
2424
+ truncated = true;
2425
+ return;
2426
+ }
2427
+ visited++;
1149
2428
  let fileStat;
1150
2429
  try {
1151
2430
  fileStat = await (0, node_fs_promises.stat)(fullPath);
@@ -1159,10 +2438,13 @@ async function collectFiles(dirPath, glob) {
1159
2438
  }
1160
2439
  }
1161
2440
  await walk(dirPath);
1162
- return results;
2441
+ return {
2442
+ files: results,
2443
+ truncated
2444
+ };
1163
2445
  }
1164
2446
  /** Search a single file for lines matching the regex. */
1165
- function searchFile(content, filePath, regex, contextLines, outputMode) {
2447
+ function searchFile(content, filePath, regex, contextLines, outputMode, maxOutputBytes) {
1166
2448
  const lines = content.split("\n");
1167
2449
  const matchingIndices = [];
1168
2450
  for (let i = 0; i < lines.length; i++) if (regex.test(lines[i])) matchingIndices.push(i);
@@ -1172,23 +2454,229 @@ function searchFile(content, filePath, regex, contextLines, outputMode) {
1172
2454
  const includedIndices = /* @__PURE__ */ new Set();
1173
2455
  for (const idx of matchingIndices) for (let c = Math.max(0, idx - contextLines); c <= Math.min(lines.length - 1, idx + contextLines); c++) includedIndices.add(c);
1174
2456
  const outputLines = [];
2457
+ let outputBytes = 0;
1175
2458
  const sortedIndices = Array.from(includedIndices).sort((a, b) => a - b);
1176
2459
  let prevIdx;
2460
+ let matchingCursor = 0;
1177
2461
  for (const idx of sortedIndices) {
1178
2462
  if (prevIdx !== void 0 && idx > prevIdx + 1) outputLines.push("--");
1179
2463
  const lineNum = idx + 1;
1180
- const marker = matchingIndices.includes(idx) ? ":" : "-";
1181
- outputLines.push(`${filePath}:${lineNum}${marker}${lines[idx]}`);
2464
+ while (matchingIndices[matchingCursor] < idx) matchingCursor++;
2465
+ const row = `${filePath}:${lineNum}${matchingIndices[matchingCursor] === idx ? ":" : "-"}${lines[idx]}`;
2466
+ outputBytes += Buffer.byteLength(row, "utf8") + 1;
2467
+ if (maxOutputBytes !== void 0 && outputBytes > maxOutputBytes) throw new Error("byte limit");
2468
+ outputLines.push(row);
1182
2469
  prevIdx = idx;
1183
2470
  }
1184
2471
  return outputLines;
1185
2472
  }
1186
- async function grepFileTool(args) {
2473
+ //#endregion
2474
+ //#region src/builtins/isolated-grep-search.ts
2475
+ const BOOTSTRAP = `
2476
+ const { parentPort } = require('node:worker_threads');
2477
+ const searchFile = ${searchFile.toString()};
2478
+ let outputBytes = 0;
2479
+ parentPort.on('message', (request) => {
2480
+ try {
2481
+ const regex = new RegExp(request.pattern);
2482
+ const matches = searchFile(request.content, request.filePath, regex, request.contextLines, request.outputMode, 4 * 1024 * 1024);
2483
+ let bytes = 0;
2484
+ for (const match of matches) { bytes += Buffer.byteLength(match, 'utf8') + 1; if (outputBytes + bytes > 4 * 1024 * 1024) throw new Error('byte limit'); }
2485
+ outputBytes += bytes;
2486
+ parentPort.postMessage({ id: request.id, matches });
2487
+ } catch (error) { parentPort.postMessage({ id: request.id, error: error?.message === 'byte limit' ? 'Grep search exceeded its byte limit' : 'Invalid grep regex execution' }); }
2488
+ });
2489
+ `;
2490
+ var GrepProcessWorker = class extends node_events.EventEmitter {
2491
+ child = (0, node_child_process.spawn)(process.execPath, ["-e", `
2492
+ const searchFile = ${searchFile.toString()};
2493
+ const readline = require('node:readline');
2494
+ let outputBytes = 0;
2495
+ readline.createInterface({ input: process.stdin }).on('line', line => {
2496
+ const request = JSON.parse(line);
2497
+ try {
2498
+ const regex = new RegExp(request.pattern);
2499
+ const matches = searchFile(request.content, request.filePath, regex, request.contextLines, request.outputMode, 4 * 1024 * 1024);
2500
+ let bytes = 0;
2501
+ for (const match of matches) { bytes += Buffer.byteLength(match, 'utf8') + 1; if (outputBytes + bytes > 4 * 1024 * 1024) throw new Error('byte limit'); }
2502
+ outputBytes += bytes;
2503
+ process.stdout.write(JSON.stringify({ id: request.id, matches }) + String.fromCharCode(10));
2504
+ } catch (error) { process.stdout.write(JSON.stringify({ id: request.id, error: error?.message === 'byte limit' ? 'Grep search exceeded its byte limit' : 'Invalid grep regex execution' }) + String.fromCharCode(10)); }
2505
+ });
2506
+ `], {
2507
+ env: {
2508
+ BUN_BE_BUN: "1",
2509
+ ...process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}
2510
+ },
2511
+ cwd: (0, node_os.tmpdir)(),
2512
+ stdio: "pipe"
2513
+ });
2514
+ closed;
2515
+ constructor() {
2516
+ super();
2517
+ let pending = "";
2518
+ this.child.stdout.setEncoding("utf8");
2519
+ this.child.stdout.on("data", (chunk) => {
2520
+ pending += chunk;
2521
+ if (Buffer.byteLength(pending, "utf8") > 25166848) {
2522
+ this.emit("error");
2523
+ return;
2524
+ }
2525
+ let newline;
2526
+ while ((newline = pending.indexOf("\n")) >= 0) {
2527
+ const line = pending.slice(0, newline);
2528
+ pending = pending.slice(newline + 1);
2529
+ try {
2530
+ this.emit("message", JSON.parse(line));
2531
+ } catch {
2532
+ this.emit("error");
2533
+ }
2534
+ }
2535
+ });
2536
+ this.child.stderr.resume();
2537
+ this.child.on("error", () => this.emit("error"));
2538
+ this.child.stdin.on("error", () => this.emit("error"));
2539
+ this.closed = new Promise((resolve) => {
2540
+ this.child.once("close", () => {
2541
+ this.emit("exit");
2542
+ resolve();
2543
+ });
2544
+ });
2545
+ }
2546
+ postMessage(request) {
2547
+ this.child.stdin.write(JSON.stringify(request) + "\n");
2548
+ }
2549
+ async terminate() {
2550
+ if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGKILL");
2551
+ await this.closed;
2552
+ }
2553
+ };
2554
+ /** One worker per grep invocation; a deadline or abort terminates it before the failure is exposed. */
2555
+ var IsolatedGrepSearch = class {
2556
+ pattern;
2557
+ signal;
2558
+ worker = process.versions.bun ? new GrepProcessWorker() : new node_worker_threads.Worker(BOOTSTRAP, {
2559
+ eval: true,
2560
+ execArgv: [],
2561
+ resourceLimits: {
2562
+ maxOldGenerationSizeMb: 128,
2563
+ maxYoungGenerationSizeMb: 32
2564
+ }
2565
+ });
2566
+ pending = /* @__PURE__ */ new Map();
2567
+ nextId = 0;
2568
+ stopped = false;
2569
+ termination;
2570
+ timer;
2571
+ abort = () => {
2572
+ this.stop(/* @__PURE__ */ new Error("Grep search cancelled"));
2573
+ };
2574
+ constructor(pattern, signal) {
2575
+ this.pattern = pattern;
2576
+ this.signal = signal;
2577
+ this.worker.on("message", (message) => {
2578
+ const pending = this.pending.get(message.id);
2579
+ if (!pending || this.stopped) return;
2580
+ this.pending.delete(message.id);
2581
+ if (Array.isArray(message.matches) && message.matches.every((m) => typeof m === "string")) pending.resolve(message.matches);
2582
+ else pending.reject(new Error(message.error ?? "Invalid grep worker response"));
2583
+ });
2584
+ this.worker.on("error", () => {
2585
+ this.stop(/* @__PURE__ */ new Error("Grep search worker failed"));
2586
+ });
2587
+ this.worker.once("exit", () => {
2588
+ this.stop(/* @__PURE__ */ new Error("Grep search worker exited"));
2589
+ });
2590
+ this.timer = setTimeout(() => {
2591
+ this.stop(/* @__PURE__ */ new Error("Grep search timed out"));
2592
+ }, 2e3);
2593
+ signal?.addEventListener("abort", this.abort, { once: true });
2594
+ if (signal?.aborted) this.abort();
2595
+ }
2596
+ search(content, filePath, contextLines, outputMode) {
2597
+ if (this.stopped) return Promise.reject(/* @__PURE__ */ new Error(this.signal?.aborted ? "Grep search cancelled" : "Grep search timed out"));
2598
+ const id = this.nextId++;
2599
+ return new Promise((resolve, reject) => {
2600
+ this.pending.set(id, {
2601
+ resolve,
2602
+ reject
2603
+ });
2604
+ try {
2605
+ this.worker.postMessage({
2606
+ id,
2607
+ content,
2608
+ filePath,
2609
+ pattern: this.pattern,
2610
+ contextLines,
2611
+ outputMode
2612
+ });
2613
+ } catch {
2614
+ this.stop(/* @__PURE__ */ new Error("Grep search worker failed"));
2615
+ }
2616
+ });
2617
+ }
2618
+ async stop(error) {
2619
+ if (this.termination) return this.termination;
2620
+ this.stopped = true;
2621
+ clearTimeout(this.timer);
2622
+ this.signal?.removeEventListener("abort", this.abort);
2623
+ this.termination = (async () => {
2624
+ try {
2625
+ await this.worker.terminate();
2626
+ } catch {}
2627
+ for (const pending of this.pending.values()) pending.reject(error ?? /* @__PURE__ */ new Error("Grep search stopped"));
2628
+ this.pending.clear();
2629
+ })();
2630
+ return this.termination;
2631
+ }
2632
+ };
2633
+ //#endregion
2634
+ //#region src/builtins/grep-tool.ts
2635
+ /**
2636
+ * GrepTool — recursive regex content search.
2637
+ *
2638
+ * Supports three output modes:
2639
+ * - files_with_matches (default): return only file paths that contain a match
2640
+ * - content: return matching lines with optional context lines
2641
+ * - count: return per-file match counts as "path:count" rows
2642
+ *
2643
+ * headLimit caps the number of result lines; excess is truncated with a marker.
2644
+ *
2645
+ * SEC-007: when a containment root is configured the search is confined to it. Grep is the most
2646
+ * disclosing of the file tools — `content` mode returns the matching LINES — so it must be contained
2647
+ * at least as strictly as `Read`, which it could otherwise stand in for.
2648
+ */
2649
+ const GrepSchema = zod.z.object({
2650
+ pattern: zod.z.string().describe("The regular expression pattern to search for in file contents"),
2651
+ path: zod.z.string().optional().describe("File or directory to search in. Defaults to the current working directory"),
2652
+ glob: zod.z.string().optional().describe("Glob pattern to filter files (e.g. \"*.ts\", \"*.{ts,tsx}\"). Only files matching this pattern will be searched"),
2653
+ contextLines: zod.z.number().optional().describe("Number of context lines to show before and after each match. Only applies when outputMode is \"content\". Default: 0"),
2654
+ outputMode: zod.z.enum([
2655
+ "files_with_matches",
2656
+ "content",
2657
+ "count"
2658
+ ]).optional().describe("Output mode: \"files_with_matches\" shows only file paths (default), \"content\" shows matching lines with context, \"count\" shows per-file match counts"),
2659
+ headLimit: zod.z.number().int().positive().optional().describe("Maximum number of result lines (file paths, content lines, or count rows) to return. Excess results are truncated with a marker line")
2660
+ });
2661
+ /** The matcher consumes one file at a time; keep only a few reads outstanding. */
2662
+ const READ_CONCURRENCY_LIMIT = 8;
2663
+ const MAX_GREP_FILE_BYTES = 4 * 1024 * 1024;
2664
+ const READ_CHUNK_BYTES = 64 * 1024;
2665
+ /** A grep isolation failure is a hard tool failure, distinct from ordinary no-match/invalid-input results. */
2666
+ var GrepIsolationError = class extends _robota_sdk_agent_core.ToolExecutionError {
2667
+ reason;
2668
+ constructor(reason) {
2669
+ super(`Grep search ${reason === "timeout" ? "timed out" : reason === "cancelled" ? "cancelled" : reason === "limit" ? "exceeded its byte limit" : "worker failed"}`, "Grep");
2670
+ this.reason = reason;
2671
+ }
2672
+ };
2673
+ async function grepFileTool(args, options) {
1187
2674
  const { pattern, path: searchPath, glob, contextLines = 0, outputMode = "files_with_matches", headLimit } = args;
1188
- const targetPath = searchPath ? (0, node_path.resolve)(searchPath) : process.cwd();
1189
- let regex;
2675
+ const containmentRoot = options.cwd;
2676
+ const { root: targetPath, error: rootError } = resolveSearchRoot(searchPath, containmentRoot);
2677
+ if (rootError) return rootError;
1190
2678
  try {
1191
- regex = new RegExp(pattern);
2679
+ new RegExp(pattern);
1192
2680
  } catch (err) {
1193
2681
  const result = {
1194
2682
  success: false,
@@ -1209,51 +2697,124 @@ async function grepFileTool(args) {
1209
2697
  return JSON.stringify(result);
1210
2698
  }
1211
2699
  let files;
2700
+ let filesTruncated = false;
1212
2701
  if (targetStat.isFile()) files = [targetPath];
1213
- else files = await collectFiles(targetPath, glob);
1214
- const allOutputLines = [];
1215
- for (const filePath of files) {
1216
- let content;
1217
- try {
1218
- const buffer = await (0, node_fs_promises.readFile)(filePath);
1219
- const checkLen = Math.min(buffer.length, 8192);
1220
- let hasBinary = false;
1221
- for (let i = 0; i < checkLen; i++) if (buffer[i] === 0) {
1222
- hasBinary = true;
1223
- break;
2702
+ else {
2703
+ const collected = await collectFiles(targetPath, glob, containmentRoot);
2704
+ files = collected.files;
2705
+ filesTruncated = collected.truncated;
2706
+ }
2707
+ const search = new IsolatedGrepSearch(pattern, options.signal);
2708
+ const readAbort = new AbortController();
2709
+ const abortReads = () => readAbort.abort();
2710
+ options.signal?.addEventListener("abort", abortReads, { once: true });
2711
+ if (options.signal?.aborted) abortReads();
2712
+ let perFileMatches;
2713
+ try {
2714
+ if (readAbort.signal.aborted) throw new GrepIsolationError("cancelled");
2715
+ const orderedMatches = new Array(files.length);
2716
+ let nextFile = 0;
2717
+ let failure;
2718
+ const readAndSearch = async (filePath) => {
2719
+ let content;
2720
+ try {
2721
+ if ((await (0, node_fs_promises.stat)(filePath)).size > MAX_GREP_FILE_BYTES) throw new GrepIsolationError("limit");
2722
+ const stream = (0, node_fs.createReadStream)(filePath, {
2723
+ highWaterMark: READ_CHUNK_BYTES,
2724
+ signal: readAbort.signal
2725
+ });
2726
+ const chunks = [];
2727
+ let bytes = 0;
2728
+ try {
2729
+ for await (const chunk of stream) {
2730
+ if (readAbort.signal.aborted) throw new GrepIsolationError("cancelled");
2731
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2732
+ bytes += buffer.length;
2733
+ if (bytes > MAX_GREP_FILE_BYTES) throw new GrepIsolationError("limit");
2734
+ chunks.push(buffer);
2735
+ }
2736
+ } finally {
2737
+ stream.destroy();
2738
+ }
2739
+ const buffer = Buffer.concat(chunks, bytes);
2740
+ const checkLen = Math.min(buffer.length, 8192);
2741
+ let hasBinary = false;
2742
+ for (let i = 0; i < checkLen; i++) if (buffer[i] === 0) {
2743
+ hasBinary = true;
2744
+ break;
2745
+ }
2746
+ if (hasBinary) return [];
2747
+ content = buffer.toString("utf8");
2748
+ } catch (error) {
2749
+ if (error instanceof GrepIsolationError) throw error;
2750
+ if (readAbort.signal.aborted) throw new GrepIsolationError("cancelled");
2751
+ return [];
1224
2752
  }
1225
- if (hasBinary) continue;
1226
- content = buffer.toString("utf8");
1227
- } catch {
1228
- continue;
1229
- }
1230
- const fileMatches = searchFile(content, filePath, regex, contextLines, outputMode);
1231
- allOutputLines.push(...fileMatches);
1232
- }
1233
- let outputLines = allOutputLines;
2753
+ return search.search(content, filePath, contextLines, outputMode);
2754
+ };
2755
+ const worker = async () => {
2756
+ while (failure === void 0 && nextFile < files.length) {
2757
+ const index = nextFile++;
2758
+ try {
2759
+ orderedMatches[index] = await readAndSearch(files[index]);
2760
+ } catch (error) {
2761
+ if (failure === void 0) {
2762
+ failure = error;
2763
+ readAbort.abort();
2764
+ search.stop(error instanceof Error ? error : /* @__PURE__ */ new Error("Grep search failed"));
2765
+ }
2766
+ }
2767
+ }
2768
+ };
2769
+ await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY_LIMIT, files.length) }, worker));
2770
+ if (failure !== void 0) throw failure;
2771
+ perFileMatches = orderedMatches;
2772
+ } catch (error) {
2773
+ const message = error instanceof Error ? error.message : "";
2774
+ throw error instanceof GrepIsolationError ? error : new GrepIsolationError(message.includes("timed out") ? "timeout" : message.includes("cancelled") ? "cancelled" : message.includes("byte limit") ? "limit" : "failed");
2775
+ } finally {
2776
+ options.signal?.removeEventListener("abort", abortReads);
2777
+ await search.stop();
2778
+ }
2779
+ let outputBytes = 0;
2780
+ for (const matches of perFileMatches) for (const match of matches) {
2781
+ outputBytes += Buffer.byteLength(match, "utf8") + 1;
2782
+ if (outputBytes > 4 * 1024 * 1024) throw new GrepIsolationError("limit");
2783
+ }
2784
+ let outputLines = perFileMatches.flat();
1234
2785
  if (headLimit !== void 0 && outputLines.length > headLimit) {
1235
2786
  const truncatedCount = outputLines.length - headLimit;
1236
2787
  outputLines = [...outputLines.slice(0, headLimit), `(+${truncatedCount} more results truncated by headLimit)`];
1237
2788
  }
2789
+ if (filesTruncated) outputLines = [...outputLines, `[File enumeration stopped early; the search tree has more files than this tool scans in one call. Results may be incomplete — narrow the path or glob.]`];
1238
2790
  const result = {
1239
2791
  success: true,
1240
2792
  output: outputLines.length > 0 ? outputLines.join("\n") : "(no matches)"
1241
2793
  };
1242
2794
  return JSON.stringify(result);
1243
2795
  }
2796
+ /** The registered name of the shell tool this package's default assembly ships (NEUT-002). */
2797
+ const DEFAULT_SHELL_TOOL_NAME = "Shell";
2798
+ /** Build the default description, referencing the actually-registered shell tool by name. */
2799
+ function buildGrepDescription(shellToolName) {
2800
+ return `A powerful search tool built on regex matching.\n\nSupports full regex syntax (e.g., 'log.*Error', 'function\\\\s+\\\\w+'). Filter files with glob parameter (e.g., '*.js', '**/*.tsx').\n\nOutput modes: 'content' shows matching lines with context, 'files_with_matches' shows only file paths (default), 'count' shows per-file match counts.\n\nPrefer this tool over running grep or rg through the ${shellToolName} tool — it returns structured results directly.\n\nUse headLimit to control result size and save context space.`;
2801
+ }
1244
2802
  /**
1245
- * GrepTool instance — register with Robota agent tools registry.
2803
+ * Create a GrepTool instance — register with Robota agent tools registry.
1246
2804
  */
1247
- const grepTool = createZodFunctionTool("Grep", "A powerful search tool built on regex matching.\n\nSupports full regex syntax (e.g., 'log.*Error', 'function\\\\s+\\\\w+'). Filter files with glob parameter (e.g., '*.js', '**/*.tsx').\n\nOutput modes: 'content' shows matching lines with context, 'files_with_matches' shows only file paths (default), 'count' shows per-file match counts.\n\nUse this tool for ALL search tasks. NEVER invoke grep or rg as a Bash command.\n\nUse headLimit to control result size and save context space.", GrepSchema, async (params) => {
1248
- return grepFileTool(params);
1249
- });
2805
+ function createGrepTool(options) {
2806
+ return createZodFunctionTool("Grep", options.description ?? buildGrepDescription(options.shellToolName ?? DEFAULT_SHELL_TOOL_NAME), GrepSchema, async (params) => {
2807
+ return grepFileTool(params, options);
2808
+ });
2809
+ }
1250
2810
  //#endregion
1251
2811
  //#region src/builtins/web-fetch-tool.ts
1252
2812
  /**
1253
2813
  * WebFetchTool — fetch a URL and return its content as text.
1254
2814
  *
1255
- * HTML is stripped to plain text for readability. Uses Node.js native fetch.
1256
- * Output is capped at 30K chars (same as other tools).
2815
+ * HTML is stripped to plain text for readability. Fetches through the shared egress boundary
2816
+ * (`fetchWithEgressPolicy`, #2026): loopback / private / link-local / metadata destinations are
2817
+ * refused, redirects are re-validated, and the response is capped while streaming.
1257
2818
  */
1258
2819
  const DEFAULT_TIMEOUT_MS$1 = 3e4;
1259
2820
  const MAX_RESPONSE_BYTES = 5e6;
@@ -1261,9 +2822,83 @@ const WebFetchSchema = zod.z.object({
1261
2822
  url: zod.z.string().describe("The URL to fetch"),
1262
2823
  headers: zod.z.record(zod.z.string()).optional().describe("Optional HTTP headers as key-value pairs")
1263
2824
  });
2825
+ /**
2826
+ * Remove every `<tag>…</tag>` element — the linear equivalent of `replace(/<tag[\s\S]*?<\/tag>/gi, '')`.
2827
+ *
2828
+ * The regex form is quadratic: every `<tag` with no closing tag after it rescans to end of input, and the scan
2829
+ * then restarts at the next one. `htmlToText`'s input is a **response body from an arbitrary URL**, capped only
2830
+ * at {@link MAX_RESPONSE_BYTES} (5 MB) — 5 MB of `<script` would have taken minutes. Because the closing tag is
2831
+ * searched forward, its absence at one opener means no later opener can have one either, so the scan stops.
2832
+ *
2833
+ * Case folding is `[A-Z]`-only, not `toLowerCase()`: `toLowerCase()` can change a string's LENGTH (U+0130
2834
+ * lowercases to two code units), which would desynchronise the indices from the original text.
2835
+ */
2836
+ function stripElement(html, tag) {
2837
+ const openTag = `<${tag}`;
2838
+ const closeTag = `</${tag}>`;
2839
+ const haystack = html.replace(/[A-Z]/g, (c) => c.toLowerCase());
2840
+ const parts = [];
2841
+ let cursor = 0;
2842
+ for (;;) {
2843
+ const open = haystack.indexOf(openTag, cursor);
2844
+ if (open < 0) break;
2845
+ const close = haystack.indexOf(closeTag, open + openTag.length);
2846
+ if (close < 0) break;
2847
+ parts.push(html.slice(cursor, open));
2848
+ cursor = close + closeTag.length;
2849
+ }
2850
+ parts.push(html.slice(cursor));
2851
+ return parts.join("");
2852
+ }
2853
+ /**
2854
+ * Replace every `<…>` tag with a space — the linear equivalent of `replace(/<[^>]+>/g, ' ')`.
2855
+ *
2856
+ * Same defect, same input: `[^>]+` cannot cross a `>`, so a `<` with no `>` after it consumed the rest of the
2857
+ * document and then backtracked over it, once per `<`. A page of 200 K `<` characters took 12.6 s; the 5 MB the
2858
+ * fetch allows would have taken hours. `close === open + 1` reproduces the regex's `+` (a tag body must be at
2859
+ * least one character), so a literal `<>` is left in the text exactly as before.
2860
+ */
2861
+ function stripTags(html) {
2862
+ const parts = [];
2863
+ let cursor = 0;
2864
+ for (;;) {
2865
+ const open = html.indexOf("<", cursor);
2866
+ if (open < 0) break;
2867
+ const close = html.indexOf(">", open + 1);
2868
+ if (close < 0) break;
2869
+ if (close === open + 1) {
2870
+ parts.push(html.slice(cursor, open + 1));
2871
+ cursor = open + 1;
2872
+ continue;
2873
+ }
2874
+ parts.push(html.slice(cursor, open), " ");
2875
+ cursor = close + 1;
2876
+ }
2877
+ parts.push(html.slice(cursor));
2878
+ return parts.join("");
2879
+ }
2880
+ /**
2881
+ * The character entities {@link htmlToText} decodes, and the single alternation that matches them.
2882
+ *
2883
+ * SEC-004 (`js/double-escaping`): decoding these by CHAINED `.replace()` calls with `&amp;` first
2884
+ * decodes twice. `&amp;lt;` — how a page encodes the literal text `&lt;` so a browser DISPLAYS it —
2885
+ * became `&lt;` after the `&amp;` pass and then `<` after the `&lt;` pass, so a page reading
2886
+ * `&amp;lt;script&amp;gt;` came back out of a tag-stripping converter as `<script>`. One pass over
2887
+ * one alternation decodes each entity exactly once and never rescans its own output, so the decoder
2888
+ * is the inverse of the encoder for every input rather than only for singly-encoded ones.
2889
+ */
2890
+ const HTML_ENTITIES = {
2891
+ "&amp;": "&",
2892
+ "&lt;": "<",
2893
+ "&gt;": ">",
2894
+ "&quot;": "\"",
2895
+ "&#39;": "'",
2896
+ "&nbsp;": " "
2897
+ };
2898
+ const HTML_ENTITY_PATTERN = /&(?:amp|lt|gt|quot|nbsp|#39);/g;
1264
2899
  /** Strip HTML tags and decode common entities to produce readable text. */
1265
2900
  function htmlToText(html) {
1266
- return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
2901
+ return stripTags(stripElement(stripElement(html, "script"), "style")).replace(HTML_ENTITY_PATTERN, (entity) => HTML_ENTITIES[entity]).replace(/\s+/g, " ").trim();
1267
2902
  }
1268
2903
  function classifyFetchError(err) {
1269
2904
  if (!(err instanceof Error)) return String(err);
@@ -1276,7 +2911,7 @@ function classifyFetchError(err) {
1276
2911
  if (code === "CERT_HAS_EXPIRED" || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") return `Network error: SSL certificate error (${code}). The server's certificate is invalid. Do not retry with the same URL.`;
1277
2912
  return `Network error: ${err.message} Check that the URL is correct and the server is reachable.`;
1278
2913
  }
1279
- async function runWebFetch(args, signal) {
2914
+ async function runWebFetch(args, egress, signal) {
1280
2915
  const { url, headers } = args;
1281
2916
  try {
1282
2917
  new URL(url);
@@ -1289,38 +2924,35 @@ async function runWebFetch(args, signal) {
1289
2924
  return JSON.stringify(result);
1290
2925
  }
1291
2926
  try {
1292
- const controller = new AbortController();
1293
- const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS$1);
1294
- const fetchSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
1295
- const response = await fetch(url, {
2927
+ const response = await (0, _robota_sdk_agent_core_node.fetchWithEgressPolicy)(url, {
1296
2928
  headers: {
1297
2929
  "User-Agent": "Robota-CLI/3.0",
1298
2930
  ...headers ?? {}
1299
2931
  },
1300
- signal: fetchSignal,
1301
- redirect: "follow"
1302
- });
1303
- clearTimeout(timeout);
2932
+ signal,
2933
+ timeoutMs: DEFAULT_TIMEOUT_MS$1,
2934
+ maxResponseBytes: MAX_RESPONSE_BYTES
2935
+ }, egress.policy, egress.deps);
1304
2936
  if (!response.ok) {
1305
- const retryHint = response.status >= 500 ? " The server is temporarily unavailable — retrying may help." : " Do not retry with the same URL.";
2937
+ const { rejection } = response;
1306
2938
  const result = {
1307
2939
  success: false,
1308
2940
  output: "",
1309
- error: `HTTP ${response.status} ${response.statusText}.${retryHint}`
2941
+ error: rejection.reason === "response_too_large" ? `Response too large (max ${MAX_RESPONSE_BYTES} bytes). Consider fetching a more specific URL or a paginated endpoint.` : `Blocked by egress policy: ${rejection.message} Do not retry with the same URL.`
1310
2942
  };
1311
2943
  return JSON.stringify(result);
1312
2944
  }
1313
- const contentType = response.headers.get("content-type") ?? "";
1314
- const buffer = await response.arrayBuffer();
1315
- if (buffer.byteLength > MAX_RESPONSE_BYTES) {
2945
+ if (response.status < 200 || response.status >= 300) {
2946
+ const retryHint = response.status >= 500 ? " The server is temporarily unavailable — retrying may help." : " Do not retry with the same URL.";
1316
2947
  const result = {
1317
2948
  success: false,
1318
2949
  output: "",
1319
- error: `Response too large: ${buffer.byteLength} bytes (max ${MAX_RESPONSE_BYTES}). Consider fetching a more specific URL or a paginated endpoint.`
2950
+ error: `HTTP ${response.status} ${response.statusText}.${retryHint}`
1320
2951
  };
1321
2952
  return JSON.stringify(result);
1322
2953
  }
1323
- let text = new TextDecoder().decode(buffer);
2954
+ const contentType = response.headers.get("content-type") ?? "";
2955
+ let text = new TextDecoder().decode(response.body);
1324
2956
  if (contentType.includes("html")) text = htmlToText(text);
1325
2957
  return JSON.stringify({
1326
2958
  success: true,
@@ -1335,64 +2967,88 @@ async function runWebFetch(args, signal) {
1335
2967
  return JSON.stringify(result);
1336
2968
  }
1337
2969
  }
1338
- const webFetchTool = createZodFunctionTool("WebFetch", "Fetch a URL and return its content as text. HTML pages are converted to plain text.", WebFetchSchema, async (params, context) => runWebFetch(params, context?.signal));
2970
+ const DEFAULT_WEB_FETCH_DESCRIPTION = "Fetch a URL and return its content as text. HTML pages are converted to plain text.";
2971
+ /**
2972
+ * Create a WebFetchTool instance — register with Robota agent tools registry.
2973
+ */
2974
+ function createWebFetchTool(options = {}) {
2975
+ const egress = options.egress ?? {};
2976
+ return createZodFunctionTool("WebFetch", options.description ?? DEFAULT_WEB_FETCH_DESCRIPTION, WebFetchSchema, async (params, context) => runWebFetch(params, egress, context?.signal));
2977
+ }
2978
+ /**
2979
+ * WebFetchTool instance — register with Robota agent tools registry.
2980
+ */
2981
+ const webFetchTool = createWebFetchTool();
2982
+ //#endregion
2983
+ //#region src/builtins/brave-search-provider.ts
2984
+ const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
2985
+ /** Brave caps `count` at 20 per request. */
2986
+ const BRAVE_MAX_COUNT = 20;
2987
+ /**
2988
+ * Create the Brave Search provider. Throws from `search()` when `BRAVE_API_KEY` is not set or
2989
+ * the API responds with an error — the tool layer surfaces the message as a structured result.
2990
+ */
2991
+ function createBraveSearchProvider() {
2992
+ return { async search({ query, limit }, signal) {
2993
+ const apiKey = process.env["BRAVE_API_KEY"];
2994
+ if (!apiKey) throw new Error("Web search requires BRAVE_API_KEY environment variable for the default Brave Search provider, or inject a custom search provider at the composition root.");
2995
+ const params = new URLSearchParams({
2996
+ q: query,
2997
+ count: String(Math.min(limit, BRAVE_MAX_COUNT))
2998
+ });
2999
+ const response = await fetch(`${BRAVE_SEARCH_ENDPOINT}?${params}`, {
3000
+ headers: {
3001
+ Accept: "application/json",
3002
+ "Accept-Encoding": "gzip",
3003
+ "X-Subscription-Token": apiKey
3004
+ },
3005
+ ...signal ? { signal } : {}
3006
+ });
3007
+ if (!response.ok) throw new Error(`Brave Search API error: HTTP ${response.status} ${response.statusText}`);
3008
+ return ((await response.json()).web?.results ?? []).map((r) => ({
3009
+ title: r.title,
3010
+ url: r.url,
3011
+ snippet: r.description
3012
+ }));
3013
+ } };
3014
+ }
1339
3015
  //#endregion
1340
3016
  //#region src/builtins/web-search-tool.ts
1341
3017
  /**
1342
3018
  * WebSearchTool — search the web and return results.
1343
3019
  *
1344
- * Uses Brave Search API when BRAVE_API_KEY is set.
1345
- * Returns an error with setup instructions otherwise.
3020
+ * Vendor-free tool layer (NEUT-008): composes over the duck-typed `IWebSearchProvider` port.
3021
+ * The default provider is the vendor-specific default adapter wired at creation time; a custom
3022
+ * provider is injected via `createWebSearchTool({ provider })`. Provider failures (missing
3023
+ * configuration, HTTP/network errors) are thrown by the provider and surfaced here as
3024
+ * structured error results.
1346
3025
  */
1347
3026
  const DEFAULT_LIMIT = 10;
1348
3027
  const DEFAULT_TIMEOUT_MS = 15e3;
3028
+ const DEFAULT_WEB_SEARCH_DESCRIPTION = "Search the web and return results with title, URL, and snippet.";
1349
3029
  const WebSearchSchema = zod.z.object({
1350
3030
  query: zod.z.string().describe("The search query"),
1351
3031
  limit: zod.z.number().optional().describe(`Maximum number of results to return (default: ${DEFAULT_LIMIT})`)
1352
3032
  });
1353
- async function runWebSearch(args, signal) {
3033
+ async function runWebSearch(args, provider, signal) {
1354
3034
  const { query, limit = DEFAULT_LIMIT } = args;
1355
- const apiKey = process.env["BRAVE_API_KEY"];
1356
- if (!apiKey) return JSON.stringify({
1357
- success: false,
1358
- output: "",
1359
- error: "Web search requires BRAVE_API_KEY environment variable. Get a free API key at https://brave.com/search/api/ (2,000 queries/month free)."
1360
- });
1361
3035
  try {
1362
3036
  const controller = new AbortController();
1363
3037
  const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
1364
- const fetchSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
1365
- const params = new URLSearchParams({
1366
- q: query,
1367
- count: String(Math.min(limit, 20))
1368
- });
1369
- const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
1370
- headers: {
1371
- Accept: "application/json",
1372
- "Accept-Encoding": "gzip",
1373
- "X-Subscription-Token": apiKey
1374
- },
1375
- signal: fetchSignal
1376
- });
1377
- clearTimeout(timeout);
1378
- if (!response.ok) {
3038
+ const searchSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
3039
+ try {
3040
+ const results = await provider.search({
3041
+ query,
3042
+ limit
3043
+ }, searchSignal);
1379
3044
  const result = {
1380
- success: false,
1381
- output: "",
1382
- error: `Brave Search API error: HTTP ${response.status} ${response.statusText}`
3045
+ success: true,
3046
+ output: JSON.stringify(results, null, 2)
1383
3047
  };
1384
3048
  return JSON.stringify(result);
3049
+ } finally {
3050
+ clearTimeout(timeout);
1385
3051
  }
1386
- const results = ((await response.json()).web?.results ?? []).map((r) => ({
1387
- title: r.title,
1388
- url: r.url,
1389
- snippet: r.description
1390
- }));
1391
- const result = {
1392
- success: true,
1393
- output: JSON.stringify(results, null, 2)
1394
- };
1395
- return JSON.stringify(result);
1396
3052
  } catch (err) {
1397
3053
  const result = {
1398
3054
  success: false,
@@ -1402,7 +3058,17 @@ async function runWebSearch(args, signal) {
1402
3058
  return JSON.stringify(result);
1403
3059
  }
1404
3060
  }
1405
- const webSearchTool = createZodFunctionTool("WebSearch", "Search the web and return results with title, URL, and snippet.", WebSearchSchema, async (params, context) => runWebSearch(params, context?.signal));
3061
+ /**
3062
+ * Create a WebSearchTool instance — register with Robota agent tools registry.
3063
+ */
3064
+ function createWebSearchTool(options = {}) {
3065
+ const provider = options.provider ?? createBraveSearchProvider();
3066
+ return createZodFunctionTool("WebSearch", options.description ?? DEFAULT_WEB_SEARCH_DESCRIPTION, WebSearchSchema, async (params, context) => runWebSearch(params, provider, context?.signal));
3067
+ }
3068
+ /**
3069
+ * WebSearchTool instance — register with Robota agent tools registry.
3070
+ */
3071
+ const webSearchTool = createWebSearchTool();
1406
3072
  //#endregion
1407
3073
  //#region src/builtins/ask-user-question-tool.ts
1408
3074
  /**
@@ -1496,8 +3162,8 @@ async function askQuestions(args, ask) {
1496
3162
  /**
1497
3163
  * Create an `AskUserQuestion` tool instance — register with the Robota agent tools registry.
1498
3164
  */
1499
- function createAskUserQuestionTool() {
1500
- return createZodFunctionTool("AskUserQuestion", ASK_USER_QUESTION_DESCRIPTION, AskUserQuestionSchema, async (params, context) => {
3165
+ function createAskUserQuestionTool(options = {}) {
3166
+ return createZodFunctionTool("AskUserQuestion", options.description ?? ASK_USER_QUESTION_DESCRIPTION, AskUserQuestionSchema, async (params, context) => {
1501
3167
  const args = params;
1502
3168
  const ask = context?.ask;
1503
3169
  const output = ask ? await askQuestions(args, ask) : {
@@ -1514,27 +3180,200 @@ function createAskUserQuestionTool() {
1514
3180
  /** `AskUserQuestion` tool instance — register with the Robota agent tools registry. */
1515
3181
  const askUserQuestionTool = createAskUserQuestionTool();
1516
3182
  //#endregion
3183
+ //#region src/builtins/tool-search-matching.ts
3184
+ /** Both vendors default a tool search to five results; so does this one. */
3185
+ const DEFAULT_TOOL_SEARCH_LIMIT = 5;
3186
+ /**
3187
+ * Where a query matched, lowest first — the primary sort key.
3188
+ *
3189
+ * A tool whose NAME the query names is a better answer than one that merely mentions it in a
3190
+ * parameter description, and saying so is what makes "the top five" meaningful once a catalog is
3191
+ * large enough for the limit to bite.
3192
+ */
3193
+ const RANK_EXACT_NAME = 0;
3194
+ const RANK_NAME = 1;
3195
+ const RANK_DESCRIPTION = 2;
3196
+ const RANK_PARAMETER = 3;
3197
+ /** Not a match at all — filtered out rather than ranked last. */
3198
+ const RANK_NONE = Number.POSITIVE_INFINITY;
3199
+ /** Every parameter name and description in a schema, including nested nodes. */
3200
+ function collectParameterText(node, into) {
3201
+ if (node.description !== void 0) into.push(node.description);
3202
+ for (const [name, child] of Object.entries(node.properties ?? {})) {
3203
+ into.push(name);
3204
+ collectParameterText(child, into);
3205
+ }
3206
+ if (node.items !== void 0) collectParameterText(node.items, into);
3207
+ for (const branch of node.anyOf ?? []) collectParameterText(branch, into);
3208
+ }
3209
+ function rankMatch(schema, query) {
3210
+ const name = schema.name.toLowerCase();
3211
+ if (name === query) return RANK_EXACT_NAME;
3212
+ if (name.includes(query)) return RANK_NAME;
3213
+ if (schema.description.toLowerCase().includes(query)) return RANK_DESCRIPTION;
3214
+ const parameterText = [];
3215
+ collectParameterText(schema.parameters, parameterText);
3216
+ if (parameterText.some((text) => text.toLowerCase().includes(query))) return RANK_PARAMETER;
3217
+ return RANK_NONE;
3218
+ }
3219
+ /**
3220
+ * The tools a query selects, best match first and capped at `limit`.
3221
+ *
3222
+ * Ordering is total and deterministic: rank first, then name, so two tools that matched the same way
3223
+ * never trade places between calls. An empty query string matches nothing rather than everything —
3224
+ * "search for nothing" is a question with an empty answer, not a request for the whole catalog.
3225
+ */
3226
+ function matchDeferredTools(schemas, query, limit) {
3227
+ const needle = query.trim().toLowerCase();
3228
+ if (needle.length === 0) return [];
3229
+ return schemas.map((schema) => ({
3230
+ schema,
3231
+ rank: rankMatch(schema, needle)
3232
+ })).filter((entry) => entry.rank !== RANK_NONE).sort((a, b) => a.rank - b.rank || (a.schema.name < b.schema.name ? -1 : 1)).slice(0, limit).map((entry) => entry.schema);
3233
+ }
3234
+ //#endregion
3235
+ //#region src/builtins/tool-search-tool.ts
3236
+ /**
3237
+ * ToolSearch — the model-facing half of client-side tool deferral (CLI-1990 § Solution 4).
3238
+ *
3239
+ * A tool that declares `deferLoading` is withheld from the request entirely while the tool-search
3240
+ * policy is engaged, so the model never sees its schema. This tool is how the model gets it back:
3241
+ * it searches the withheld catalog by query, or loads an exact list by name, and the runtime marks
3242
+ * the matches loaded — so the NEXT round's `tools` array carries their full definitions and they
3243
+ * stay callable for the rest of the session.
3244
+ *
3245
+ * Deliberately an ordinary function tool rather than a vendor block. Anthropic and OpenAI each ship
3246
+ * a server-side tool search, but neither reduces the request payload (the API needs every definition
3247
+ * to run the search), Gemini has no equivalent at all, and both vendors document a client-executed
3248
+ * search as the portable form. One shape therefore runs everywhere and saves both wire bytes and
3249
+ * context tokens.
3250
+ *
3251
+ * Two results are NOT errors, and the distinction is the contract:
3252
+ * - a query that matches nothing returns `{ loaded: [], unavailableSources: [] }` — a normal empty
3253
+ * answer, mirroring the vendor's own empty `tool_references` array;
3254
+ * - an unknown entry in `names` throws, naming the entry, and loads nothing — asking for a tool that
3255
+ * does not exist is a mistake to correct, not an empty search.
3256
+ *
3257
+ * `unavailableSources` is present and empty from day one. MCP-003 (the connection and capability
3258
+ * supervisor) fills it with servers that failed or need auth, so a model told "nothing matched" can
3259
+ * tell that apart from "the server holding it is down" — without a contract change here.
3260
+ */
3261
+ /**
3262
+ * The registered name — agent-core's own constant, re-exported under this package's name so the
3263
+ * execution layer's unknown-tool remedy and this tool can never name two different things.
3264
+ */
3265
+ const TOOL_SEARCH_NAME = _robota_sdk_agent_core.TOOL_SEARCH_TOOL_NAME;
3266
+ const ToolSearchSchema = zod.z.object({
3267
+ query: zod.z.string().optional().describe("Text matched case-insensitively against each withheld tool's name, description, and its parameters' names and descriptions. An exact tool name is a valid query."),
3268
+ names: zod.z.array(zod.z.string().min(1)).optional().describe("Exact tool names to load, skipping the search. An unknown name is an error naming it."),
3269
+ limit: zod.z.number().int().positive().optional().describe(`Maximum tools to load from a query match (default 5).`)
3270
+ });
3271
+ const TOOL_SEARCH_DESCRIPTION = [
3272
+ "Load tools whose definitions are withheld from your tool list, so you can call them.",
3273
+ "",
3274
+ "Some tools are deferred: they exist and are callable, but their schemas are not sent to you until",
3275
+ "you load them here. If a capability you need is not in your tool list, search for it before",
3276
+ "concluding it is unavailable — and if a tool call fails as \"deferred and not yet loaded\", load it",
3277
+ "with this tool and call it again.",
3278
+ "",
3279
+ " - query: what you are trying to do (e.g. \"read a spreadsheet\", \"postgres\"). Matched against tool",
3280
+ " names, descriptions, and parameter names and descriptions. An exact tool name works too.",
3281
+ ` - names: load exactly these tools, skipping the search. Unknown names are an error.`,
3282
+ ` - limit: how many matches to load (default 5).`,
3283
+ "",
3284
+ "The result lists what is now loaded; those tools appear in your tool list from your next turn and",
3285
+ "stay available. A query that matches nothing returns an empty list — that is a normal answer, not",
3286
+ "a failure. `unavailableSources` names any tool source that could not be consulted."
3287
+ ].join("\n");
3288
+ /**
3289
+ * The catalog the runtime injects, or a thrown wiring error.
3290
+ *
3291
+ * Its absence is not a runtime condition to degrade around: `ToolExecutionService` attaches this
3292
+ * port to every tool call it issues, so a missing one means this tool was invoked outside the
3293
+ * execution loop. Guessing an empty catalog there would report "nothing matched" for a search that
3294
+ * was never actually run.
3295
+ */
3296
+ function requireCatalog(context) {
3297
+ const catalog = context?.deferredTools;
3298
+ if (!catalog) throw new Error(`${TOOL_SEARCH_NAME} requires the deferred-tool catalog, which the execution runtime injects; it was not present, so this tool was called outside the agent execution loop.`);
3299
+ return catalog;
3300
+ }
3301
+ /** Which schemas this call loads: the exact `names`, else the query's ranked matches. */
3302
+ function selectTools(args, catalog) {
3303
+ if (args.names !== void 0) return catalog.loadDeferredTools(args.names);
3304
+ if (args.query === void 0) throw new Error(`${TOOL_SEARCH_NAME} needs either "query" to search for tools or "names" to load exact ones.`);
3305
+ const matches = matchDeferredTools(catalog.listDeferredTools(), args.query, args.limit ?? 5);
3306
+ return catalog.loadDeferredTools(matches.map((schema) => schema.name));
3307
+ }
3308
+ /**
3309
+ * Create a `ToolSearch` tool instance — register it RESIDENT with the agent's tool registry.
3310
+ *
3311
+ * It must never itself be deferred: a search tool the model cannot see is a catalog with no way in,
3312
+ * which is the state the vendor's own "at least one tool must stay resident" invariant forbids.
3313
+ */
3314
+ function createToolSearchTool(options = {}) {
3315
+ return createZodFunctionTool(TOOL_SEARCH_NAME, options.description ?? TOOL_SEARCH_DESCRIPTION, ToolSearchSchema, async (params, context) => {
3316
+ const output = {
3317
+ loaded: selectTools(params, requireCatalog(context)).map(({ name, description }) => ({
3318
+ name,
3319
+ description
3320
+ })),
3321
+ unavailableSources: []
3322
+ };
3323
+ const result = {
3324
+ success: true,
3325
+ output: JSON.stringify(output)
3326
+ };
3327
+ return JSON.stringify(result);
3328
+ });
3329
+ }
3330
+ /** `ToolSearch` tool instance — register with the Robota agent tools registry. */
3331
+ const toolSearchTool = createToolSearchTool();
3332
+ //#endregion
3333
+ exports.DEFAULT_OS_SANDBOX_SETTINGS = DEFAULT_OS_SANDBOX_SETTINGS;
3334
+ exports.DEFAULT_TOOL_SEARCH_LIMIT = DEFAULT_TOOL_SEARCH_LIMIT;
1517
3335
  exports.E2BSandboxClient = E2BSandboxClient;
1518
- exports.FunctionTool = FunctionTool;
3336
+ exports.GrepIsolationError = GrepIsolationError;
1519
3337
  exports.InMemorySandboxClient = InMemorySandboxClient;
1520
- exports.ToolRegistry = ToolRegistry;
3338
+ exports.OsSandboxClient = OsSandboxClient;
3339
+ exports.PageComputerDriver = PageComputerDriver;
3340
+ exports.REPO_MAP_INDEX_VERSION = REPO_MAP_INDEX_VERSION;
3341
+ exports.ReadByteLimitError = ReadByteLimitError;
3342
+ exports.ReadCancelledError = ReadCancelledError;
3343
+ exports.RepoMapRetrievalAdapter = RepoMapRetrievalAdapter;
3344
+ exports.TOOL_SEARCH_NAME = TOOL_SEARCH_NAME;
1521
3345
  exports.applyWorkspaceManifest = applyWorkspaceManifest;
1522
3346
  exports.askUserQuestionTool = askUserQuestionTool;
1523
- exports.bashTool = bashTool;
3347
+ exports.bubblewrapArguments = bubblewrapArguments;
3348
+ exports.buildRepoMapIndex = buildRepoMapIndex;
1524
3349
  exports.createAskUserQuestionTool = createAskUserQuestionTool;
1525
3350
  exports.createBashTool = createBashTool;
3351
+ exports.createBraveSearchProvider = createBraveSearchProvider;
3352
+ exports.createComputerActTool = createComputerActTool;
3353
+ exports.createComputerTool = createComputerTool;
3354
+ exports.createComputerViewTool = createComputerViewTool;
1526
3355
  exports.createEditTool = createEditTool;
1527
3356
  exports.createFunctionTool = createFunctionTool;
3357
+ exports.createGlobTool = createGlobTool;
3358
+ exports.createGrepTool = createGrepTool;
1528
3359
  exports.createReadTool = createReadTool;
3360
+ exports.createRetrievalTool = createRetrievalTool;
1529
3361
  exports.createShellTool = createShellTool;
3362
+ exports.createToolSearchTool = createToolSearchTool;
3363
+ exports.createWebFetchTool = createWebFetchTool;
3364
+ exports.createWebSearchTool = createWebSearchTool;
1530
3365
  exports.createWriteTool = createWriteTool;
1531
3366
  exports.createZodFunctionTool = createZodFunctionTool;
1532
- exports.editTool = editTool;
1533
- exports.globTool = globTool;
1534
- exports.grepTool = grepTool;
1535
- exports.readTool = readTool;
1536
- exports.shellTool = shellTool;
3367
+ exports.describeExecutionContainment = describeExecutionContainment;
3368
+ exports.deserializeRepoMapIndex = deserializeRepoMapIndex;
3369
+ exports.detectOsSandbox = detectOsSandbox;
3370
+ exports.matchDeferredTools = matchDeferredTools;
3371
+ exports.protectedWorkspaceEntries = protectedWorkspaceEntries;
3372
+ exports.routesFilesThroughSandbox = routesFilesThroughSandbox;
3373
+ exports.seatbeltProfile = seatbeltProfile;
3374
+ exports.serializeRepoMapIndex = serializeRepoMapIndex;
3375
+ exports.toolSearchTool = toolSearchTool;
3376
+ exports.updateRepoMapIndex = updateRepoMapIndex;
1537
3377
  exports.validateWorkspaceManifestPath = validateWorkspaceManifestPath;
1538
3378
  exports.webFetchTool = webFetchTool;
1539
3379
  exports.webSearchTool = webSearchTool;
1540
- exports.writeTool = writeTool;