bare-agent 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -425,7 +425,7 @@ naming the opt-in — rather than silently ignoring it. Pass `confirmServer(name
425
425
  def) => boolean` to approve each server **before its command is spawned** (return
426
426
  `false` to skip it; a throw fails closed). When no `confirmServer` is set, the
427
427
  bridge still trusts all *discovered* servers and prints a one-time warning naming
428
- every command it is about to spawn — `confirmServer` is how you actually *gate* it.
428
+ every server and command it is about to spawn (an arg count only, never arg or env values) — `confirmServer` is how you actually *gate* it.
429
429
 
430
430
  **RPC timeouts (Unreleased).** Every JSON-RPC round-trip is now bounded, so a
431
431
  server that never answers can't hang the bridge or the loop. `opts.timeout`
@@ -435,8 +435,13 @@ timed-out tool call rejects with a labelled `ToolError`
435
435
  (`MCP server "s" tool "t" timed out after Nms`, `context: { timeout: true, server, tool, timeoutMs, stderr }` —
436
436
  stderr is in `context`, not the message; `wireGate`'s `onToolResult` forwards only the
437
437
  non-stderr label fields to the audit row as an allow-listed `errorContext`) rather
438
- than blocking forever; a server that breaks its stdin pipe surfaces as a
439
- failed connection, never an uncaught `EPIPE` crash.
438
+ than blocking forever; a handshake timeout (`initialize`/`tools/list`) is labelled the same way
439
+ (`MCP server "s" "method" timed out after Nms`, `context: { timeout: true, server, method, timeoutMs, stderr }`);
440
+ a server that breaks its stdin pipe surfaces as a failed connection, never an uncaught `EPIPE`
441
+ crash; and a server whose `command` cannot start (ENOENT/EACCES, or a sync `spawn()` refusal such as an empty command or NUL byte) rejects with
442
+ `MCP server "s" failed to start: command "<cmd>" (<code>)`, `context: { spawnError: true, server, code }`
443
+ (`code` = Node error code if a string, else `'ESPAWN'`; command only with control chars escaped, never args/env) — `listServerTools` throws it, `allow` mode puts it in `errors` + `missing`,
444
+ bulk mode in `errors` — instead of crashing the host.
440
445
 
441
446
  ## Wiring with bareguard
442
447
 
@@ -1454,6 +1459,8 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1454
1459
  >
1455
1460
  > **Opt-in symlink refusal:** `createShellTools({ noFollowSymlinks: true })` makes `shell_read`, `shell_write`, `shell_edit`, and `shell_grep` refuse to open a path whose **final** path component is a symlink — a file, a dir, or dangling — instead of silently following it. Refusal is a **thrown** error with `err.code === 'ELOOP'` (feeds the Loop's `maxIdenticalToolErrors` guard like any other tool error), and for `shell_write` the link's target is never created or touched. Scope is the final component **only** — a symlinked *parent* directory is not covered (per the canonicalization note above), and neither is the `cwd` option of `shell_run`/`shell_exec` — it only gets `~` expansion, never this flag or `resolveToolPath`'s full canonicalization, so a symlinked `cwd` is always followed. `shell_grep` refuses only a symlinked **root** path loudly, via the same atomic open every other tool uses (open-then-fstat, not a look-then-open `lstat`); a file that turns into a symlink partway through the walk is skipped silently, the same as any other read error. The check is atomic (`O_NOFOLLOW`) on linux/macOS; on Windows, which has no `O_NOFOLLOW`-equivalent open flag, it falls back to a non-atomic `lstat`-then-open check — a symlink planted in that gap slips through. A directory listing (`shell_read` on a dir, `shell_grep`'s root when it's a dir) additionally rechecks the path's `dev`+`ino` against the open handle right after listing — Node has no `fdopendir`, so the listing itself still goes by path, and this narrows (does not close) the window where a directory swapped in between the open and the listing would report a different directory's names; a swap-and-swap-back inside that narrow window still passes undetected. Default `false` — omitting the option is byte-identical to the pre-existing behavior.
1456
1461
  >
1462
+ > **Opt-in `canRead` (F259) — closes the root-grep bypass of `fs.deny`.** The Loop `policy` gates `shell_grep` as ONE action on the ROOT path, so `shell_grep` on a repo root walks into paths a gate `fs.deny`s (a direct `shell_read` of them is denied). `createShellTools({ canRead: async (absPath, { isDir, tool }) => boolean })` makes `shell_grep` ask before every directory (`false` prunes the whole subtree, never listed) and every file (`false` skips silently); the root is not passed. Wire bareguard as `canRead: (p, { tool }) => gate.allows({ type: 'read', tool, path: p })`. Unset ⇒ byte-identical; when set the walk runs on the main thread and the worker reads only the allowed list. A throw or non-boolean return rejects the grep (type only in the message), `HaltError` propagates, a non-function `canRead` throws `ValidationError` at creation. `shell_read` on a dir is unaffected (names of one dir, gated on its own path). The grep `timeout` bounds the WHOLE call including the canRead walk (a slow/hung `canRead` rejects with a `canRead walk exceeded` error). TOCTOU: `canRead` vets paths at walk time; a path swapped for a symlink between vetting and the worker's read is followed unless `noFollowSymlinks:true` — pair `canRead` with `noFollowSymlinks:true`. `shell_grep` builds its worker payload from an explicit allow-list: a model-supplied `files` arg is ignored (only the `canRead`-vetted list, set by `grepPath` itself, reaches the worker). A `timeout` above 2^31-1 is clamped to 2^31-1 (Node's timer max); non-finite, zero, negative or non-number values use the 5000ms default. Cost on a 5.4k-file tree: ~2.5s → ~3.7s with a real gate. See `poc/f259-grep-canread.mjs`.
1463
+ >
1457
1464
  > **`resolveToolPath(p)`** — exported alongside `createShellTools` from `bare-agent/tools`. THE canonicalizer every shell file tool applies internally before opening: expand `~`/`~/…` via `os.homedir()` (throws rather than silently degrading `~/x` to `/x` when no home directory can be determined), then `path.resolve` against `process.cwd()`. Idempotent; rejects a non-string/empty path (thrown, naming only the type/category of the bad value, never the value itself) — every file tool routes through it, so `shell_read`/`shell_grep` with `""` now error instead of silently reading/searching `process.cwd()`. Call it in your `actionTranslator` on `args.path` before returning the gated action, so the string the gate judges is the exact string the tool opens.
1458
1465
 
1459
1466
  > **⚠️ `shell_write` requires `content` — and a gate cannot cover for it (v0.27+).** `content` used to default to `''`, so a tool call that OMITTED it silently overwrote the target with **zero bytes** and returned `"wrote 0 bytes to <path>"` as success. That is the ordinary shape of a model hitting its **output-token cap** mid-generation on a long file — observed live emptying a 1789-line source file. **No policy can catch it:** a 0-byte write is a *legal* write, and bareguard's `fs` primitive judges `{type:'write', path}` without inspecting the body (the gate correctly `allow`s it). `shell_write` now **rejects** an absent, `null`, or non-string `content` and leaves the file byte-identical; the error tells the model to retry with the full content. An explicit `content: ""` still empties the file — that one is deliberate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/primitives.json CHANGED
@@ -141,7 +141,7 @@
141
141
  "category": "tools",
142
142
  "when": "you want to give an agent shell/file tools (read, grep, write, edit, run, exec) — cross-platform, pure Node, zero deps",
143
143
  "import": "import { createShellTools } from 'bare-agent/tools'",
144
- "signature": "createShellTools(options?: {noFollowSymlinks?: boolean}) => {tools: ToolDef[]}",
144
+ "signature": "createShellTools(options?: {noFollowSymlinks?: boolean, canRead?: CanRead}) => {tools: ToolDef[]}",
145
145
  "fails": "never throws at creation; gating is the caller's via Loop({ policy }) and fs.writeScope, shell_edit refuses a non-unique anchor as a tool result (file untouched), and with noFollowSymlinks:true the four file tools throw ELOOP on a symlinked final path component.",
146
146
  "example": "const { tools } = createShellTools();\nconst loop = new Loop({ provider, tools, policy });\nconst { tools: safeTools } = createShellTools({ noFollowSymlinks: true });"
147
147
  },
package/src/mcp-bridge.js CHANGED
@@ -231,6 +231,23 @@ function resolveEnv(env) {
231
231
 
232
232
  // --- JSON-RPC stdio client ---
233
233
 
234
+ /**
235
+ * Labelled spawn-failure error. Names the command ONLY (args/env can carry tokens); control
236
+ * characters in the command are escaped so a NUL/newline can't corrupt logs. `code` is the Node
237
+ * error code when it is a string, else 'ESPAWN'.
238
+ * @param {string} name
239
+ * @param {any} command
240
+ * @param {any} err
241
+ * @returns {ToolError}
242
+ */
243
+ function spawnFailure(name, command, err) {
244
+ const code = typeof err?.code === 'string' ? err.code : 'ESPAWN';
245
+ const shown = String(command).replace(/[\u0000-\u001f\u007f]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
246
+ return new ToolError(`MCP server "${name}" failed to start: command "${shown}" (${code})`, {
247
+ context: { spawnError: true, server: name, code },
248
+ });
249
+ }
250
+
234
251
  /**
235
252
  * @param {string} name
236
253
  * @param {ServerDef} def
@@ -240,11 +257,28 @@ function createRpcClient(name, def) {
240
257
  const { command, args = [], env, cwd } = def;
241
258
  const mergedEnv = { ...process.env, ...resolveEnv(env) };
242
259
 
243
- const child = spawn(command, args, {
244
- stdio: ['pipe', 'pipe', 'pipe'],
245
- env: mergedEnv,
246
- ...(cwd && { cwd }),
247
- });
260
+ /** @type {import('node:child_process').ChildProcessWithoutNullStreams} */
261
+ let child;
262
+ try {
263
+ child = spawn(command, args, {
264
+ stdio: ['pipe', 'pipe', 'pipe'],
265
+ env: mergedEnv,
266
+ ...(cwd && { cwd }),
267
+ });
268
+ } catch (err) {
269
+ // spawn() throws SYNCHRONOUSLY for some bad input (empty command, NUL byte, E2BIG), so no
270
+ // child 'error' event will ever fire. Same labelled shape as the async path; never the args.
271
+ // Dead client: rpc() rejects every call; notify() is a no-op; `child` is a minimal stand-in
272
+ // that killServer() skips (exitCode !== null) — the simplest shape that needs no null-guards
273
+ // in connectAndListTools / killServer / close().
274
+ const dead = spawnFailure(name, command, err);
275
+ return {
276
+ rpc: () => Promise.reject(dead),
277
+ notify: () => {},
278
+ child: /** @type {any} */ ({ exitCode: -1, pid: undefined }),
279
+ stderr: '',
280
+ };
281
+ }
248
282
 
249
283
  /** @type {Map<number, {resolve: (v: any) => void, reject: (e: any) => void, timer: NodeJS.Timeout | null}>} */
250
284
  const pending = new Map();
@@ -297,9 +331,25 @@ function createRpcClient(name, def) {
297
331
  // and rpc()/notify() guard writability before writing.
298
332
  child.stdin.on('error', () => { /* child gone; surfaced via close + write guards */ });
299
333
 
334
+ // Spawn failure (ENOENT/EACCES/...): with NO 'error' listener Node re-throws it as an uncaught
335
+ // exception and crashes the HOST. Record it once; it WINS over any later pipe/close error and
336
+ // rejects every pending and future rpc. Names the command ONLY (args/env can carry tokens).
337
+ /** @type {ToolError | null} */
338
+ let spawnErr = null;
339
+ child.on('error', (err) => {
340
+ // Only a child that never got a pid is a spawn failure. Node also emits 'error' for a failed
341
+ // kill() or IPC send on a LIVE child; keep the listener (so it can't crash the host) but do
342
+ // not mark the server failed — a live server's lifecycle belongs to the 'close' handler.
343
+ if (child.pid !== undefined || spawnErr) return;
344
+ spawnErr = spawnFailure(name, command, err);
345
+ for (const id of [...pending.keys()]) settle(id, false, spawnErr);
346
+ });
347
+
300
348
  child.on('close', (code) => {
301
349
  for (const id of [...pending.keys()]) {
302
- settle(id, false, new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
350
+ // Belt-and-braces: on Node 22 the observed order is error -> EPIPE -> close, so the 'error'
351
+ // handler has already settled these ids; `spawnErr ||` guards a different ordering elsewhere.
352
+ settle(id, false, spawnErr || new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
303
353
  }
304
354
  });
305
355
 
@@ -318,6 +368,7 @@ function createRpcClient(name, def) {
318
368
  function rpc(method, params = {}, timeoutMs = 0, label) {
319
369
  const id = nextId++;
320
370
  return new Promise((resolve, reject) => {
371
+ if (spawnErr) return reject(spawnErr);
321
372
  if (!child.stdin.writable) {
322
373
  return reject(new ToolError(`MCP server "${name}" stdin is not writable (process exited or pipe closed). stderr: ${stderrBuf.slice(-500)}`));
323
374
  }
@@ -331,7 +382,10 @@ function createRpcClient(name, def) {
331
382
  { context: { timeout: true, server: label.server, tool: label.tool, timeoutMs, stderr: stderrBuf.slice(-500) } },
332
383
  ));
333
384
  } else {
334
- settle(id, false, new ToolError(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
385
+ settle(id, false, new ToolError(
386
+ `MCP server "${name}" "${method}" timed out after ${timeoutMs}ms`,
387
+ { context: { timeout: true, server: name, method, timeoutMs, stderr: stderrBuf.slice(-500) } },
388
+ ));
335
389
  }
336
390
  }, timeoutMs);
337
391
  timer.unref?.();
@@ -340,7 +394,10 @@ function createRpcClient(name, def) {
340
394
  const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
341
395
  child.stdin.write(msg, (err) => {
342
396
  // settle() no-ops if 'close'/timeout already settled this id.
343
- if (err) settle(id, false, new ToolError(`MCP server "${name}" write failed: ${err.message}. stderr: ${stderrBuf.slice(-500)}`));
397
+ // Belt-and-braces (Node 22 order is error -> EPIPE -> close, already settled by 'error').
398
+ // Deferred a tick: a failed spawn's EPIPE/destroyed-stream callback can land before the
399
+ // 'error' event; the recorded spawn error must win, so re-check after it has had its turn.
400
+ if (err) setImmediate(() => settle(id, false, spawnErr || new ToolError(`MCP server "${name}" write failed: ${err.message}. stderr: ${stderrBuf.slice(-500)}`)));
344
401
  });
345
402
  });
346
403
  }
@@ -724,7 +781,8 @@ function makeUnvettedWarner(confirmServer) {
724
781
  return (specs) => {
725
782
  if (confirmServer || warned || specs.length === 0) return;
726
783
  warned = true;
727
- const cmds = specs.map(s => `${s.name} → ${s.command} ${(s.args || []).join(' ')}`.trim());
784
+ // Server name + command ONLY: args/env can carry tokens and stderr lands in logs. A count only.
785
+ const cmds = specs.map(s => `${s.name} → ${s.command}${s.args && s.args.length ? ` (${s.args.length} args)` : ''}`);
728
786
  console.warn(
729
787
  `[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
730
788
  cmds.join('\n ') +
@@ -14,7 +14,7 @@ const { _grepCore } = require('./shell.js');
14
14
  if (!parentPort) throw new Error('grep-worker.js must be run as a worker thread');
15
15
  const port = parentPort;
16
16
 
17
- _grepCore(workerData).then(
17
+ _grepCore(workerData.args, workerData.files).then(
18
18
  (result) => port.postMessage({ ok: true, result }),
19
19
  (err) => port.postMessage({ ok: false, error: err && err.message ? err.message : String(err) }),
20
20
  );
package/tools/shell.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  declare namespace _exports {
2
- export { GrepArgs, RunArgvArgs, ExecCommandArgs, ToolDef };
2
+ export { GrepArgs, CanRead, RunArgvArgs, ExecCommandArgs, ToolDef };
3
3
  }
4
4
  declare namespace _exports {
5
5
  export { createShellTools };
@@ -21,7 +21,9 @@ type GrepArgs = {
21
21
  /**
22
22
  * - Hard wall-clock ceiling in ms (default 5000). The match runs in a
23
23
  * worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips
24
- * past `looksCatastrophic` can no longer hang the host event loop.
24
+ * past `looksCatastrophic` can no longer hang the host event loop. When `canRead` is set it bounds
25
+ * the WHOLE call, the canRead walk included. Values above 2^31-1 are clamped to 2^31-1 (Node's
26
+ * timer max); non-finite, zero, negative or absent values use the default.
25
27
  */
26
28
  timeout?: number | undefined;
27
29
  /**
@@ -31,6 +33,10 @@ type GrepArgs = {
31
33
  */
32
34
  noFollowSymlinks?: boolean | undefined;
33
35
  };
36
+ type CanRead = (absPath: string, info: {
37
+ isDir: boolean;
38
+ tool: string;
39
+ }) => Promise<boolean> | boolean;
34
40
  type RunArgvArgs = {
35
41
  argv: string[];
36
42
  cwd?: string | undefined;
@@ -50,7 +56,7 @@ type ToolDef = import("../types").ToolDef;
50
56
  * Create the six shell tools. Configuration is mostly per-call via tool args; gating is the
51
57
  * caller's responsibility via `new Loop({ policy })`.
52
58
  *
53
- * @param {{noFollowSymlinks?: boolean}} [options] - `noFollowSymlinks` (default `false`, byte-
59
+ * @param {{noFollowSymlinks?: boolean, canRead?: CanRead}} [options] - `noFollowSymlinks` (default `false`, byte-
54
60
  * identical to the pre-existing behavior when omitted): when `true`, `shell_read`, `shell_write`,
55
61
  * `shell_edit`, and `shell_grep` refuse to open a path whose FINAL path component is a symlink
56
62
  * (file, dir, or dangling) — THROWN, `err.code:'ELOOP'`, never a silently-followed link. Scope is
@@ -65,6 +71,20 @@ type ToolDef = import("../types").ToolDef;
65
71
  * not a bug. Set per `createShellTools()` instance, not module-global — safe to mix a
66
72
  * `noFollowSymlinks:true` toolset for one agent alongside a default toolset for another in the same
67
73
  * process.
74
+ * `canRead` (opt-in; unset => byte-identical to before): `async (absPath, {isDir, tool}) => boolean`,
75
+ * consulted by `shell_grep` for EVERY directory (before descending; `false` prunes the whole subtree,
76
+ * never listed) and file (`false` skips it silently) found under a directory root. Closes the
77
+ * root-grep bypass of a gate's `fs.deny`: the Loop policy gates only the ROOT path, so a deny on
78
+ * `<root>/.secret` is invisible to a grep of `<root>`. The root itself is NOT passed (already
79
+ * policy-gated). Wire bareguard as `(p, {tool}) => gate.allows({type:'read', tool, path:p})`. When
80
+ * set, the walk runs on the main thread (the gate can't cross into the Worker) and the worker
81
+ * receives only the allowed list. Fail-closed LOUD: a throw or non-boolean return rejects the grep
82
+ * (type only in the message); `HaltError` propagates as-is. A non-function `canRead` throws
83
+ * `ValidationError` at creation. `shell_read` on a directory lists one dir's names and is gated on
84
+ * its own path — unaffected. The grep `timeout` bounds the WHOLE call, the canRead walk included (a slow or
85
+ * hung `canRead` rejects with a distinct `canRead walk exceeded` error). TOCTOU: `canRead` vets paths at WALK
86
+ * time; a path swapped for a symlink between vetting and the worker's read is followed unless
87
+ * `noFollowSymlinks:true` — pair `canRead` with `noFollowSymlinks:true`.
68
88
  * @returns {{tools: ToolDef[]}}
69
89
  * @when you want to give an agent shell/file tools (read, grep, write, edit, run, exec) — cross-platform, pure Node, zero deps
70
90
  * @fails never throws at creation; gating is the caller's via Loop({ policy }) and fs.writeScope, shell_edit refuses a non-unique anchor as a tool result (file untouched), and with noFollowSymlinks:true the four file tools throw ELOOP on a symlinked final path component.
@@ -75,6 +95,7 @@ type ToolDef = import("../types").ToolDef;
75
95
  */
76
96
  declare function createShellTools(options?: {
77
97
  noFollowSymlinks?: boolean;
98
+ canRead?: CanRead;
78
99
  }): {
79
100
  tools: ToolDef[];
80
101
  };
@@ -101,20 +122,6 @@ declare function createShellTools(options?: {
101
122
  * const resolved = resolveToolPath('~/notes.txt');
102
123
  */
103
124
  declare function resolveToolPath(p: string): string;
104
- /**
105
- * @typedef {object} GrepArgs
106
- * @property {string} pattern
107
- * @property {string} path
108
- * @property {boolean} [recursive]
109
- * @property {number} [maxMatches]
110
- * @property {string} [flags]
111
- * @property {number} [timeout] - Hard wall-clock ceiling in ms (default 5000). The match runs in a
112
- * worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips
113
- * past `looksCatastrophic` can no longer hang the host event loop.
114
- * @property {boolean} [noFollowSymlinks] - When true, refuses a symlinked ROOT path (thrown, loud)
115
- * and skips (silently, like any other read error) a file that is or becomes a symlink during the
116
- * walk. Threaded into `workerData` so the worker thread's file reads honor it too.
117
- */
118
125
  /**
119
126
  * The actual search: walk, skip binaries, regex-test each line. Runs in a worker thread (see
120
127
  * grep-worker.js) so a runaway regex is killable via `worker.terminate()`. JS RegExp has no
@@ -122,8 +129,10 @@ declare function resolveToolPath(p: string): string;
122
129
  * only sound bound (the static `looksCatastrophic` guard is a best-effort fast-reject, not a
123
130
  * guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially).
124
131
  * @param {GrepArgs} args
132
+ * @param {string[]} [providedFiles] - INTERNAL, never from tool args: the `canRead`-vetted file list
133
+ * (passed by grep-worker.js from its own workerData key, set only by `grepPath`).
125
134
  */
126
- declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags, noFollowSymlinks }: GrepArgs): Promise<{
135
+ declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags, noFollowSymlinks }: GrepArgs, providedFiles?: string[]): Promise<{
127
136
  hits: {
128
137
  file: string;
129
138
  line: number;
package/tools/shell.js CHANGED
@@ -66,12 +66,14 @@ const os = require('node:os');
66
66
  const crypto = require('node:crypto');
67
67
  const { exec, execFile } = require('node:child_process');
68
68
  const { Worker } = require('node:worker_threads');
69
+ const { HaltError, ValidationError } = require('../src/errors');
69
70
 
70
71
  const fsConstants = fs.constants;
71
72
 
72
73
  const DEFAULT_READ_MAX_BYTES = 256 * 1024; // 256 KB
73
74
  const DEFAULT_WRITE_MAX_BYTES = 5 * 1024 * 1024; // 5 MB — a sanity ceiling on a single write (LLM-authored)
74
75
  const DEFAULT_GREP_MAX_MATCHES = 200;
76
+ const MAX_TIMER_MS = 2_147_483_647; // setTimeout's max delay; larger overflows to 1ms
75
77
  const DEFAULT_GREP_TIMEOUT_MS = 5_000; // hard ceiling on a single grep — bounds ReDoS
76
78
  const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
77
79
  const DEFAULT_EXEC_MAX_BUFFER = 1024 * 1024; // 1 MB
@@ -583,12 +585,93 @@ function looksCatastrophic(pattern) {
583
585
  * @property {string} [flags]
584
586
  * @property {number} [timeout] - Hard wall-clock ceiling in ms (default 5000). The match runs in a
585
587
  * worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips
586
- * past `looksCatastrophic` can no longer hang the host event loop.
588
+ * past `looksCatastrophic` can no longer hang the host event loop. When `canRead` is set it bounds
589
+ * the WHOLE call, the canRead walk included. Values above 2^31-1 are clamped to 2^31-1 (Node's
590
+ * timer max); non-finite, zero, negative or absent values use the default.
587
591
  * @property {boolean} [noFollowSymlinks] - When true, refuses a symlinked ROOT path (thrown, loud)
588
592
  * and skips (silently, like any other read error) a file that is or becomes a symlink during the
589
593
  * walk. Threaded into `workerData` so the worker thread's file reads honor it too.
590
594
  */
591
595
 
596
+ /**
597
+ * @callback CanRead
598
+ * @param {string} absPath
599
+ * @param {{isDir: boolean, tool: string}} info
600
+ * @returns {Promise<boolean>|boolean}
601
+ */
602
+
603
+ /**
604
+ * Ask the caller's `canRead` arbiter about one path. Fail-closed LOUD: a throw or any non-boolean
605
+ * (truthy objects, undefined, 1, a Promise resolving to 'yes') rejects the whole grep — a broken
606
+ * arbiter must never read as allow OR as a silent deny. HaltError propagates as-is. Error text names
607
+ * the tool, the path and the TYPE of a bad value only, never the value.
608
+ * The call is raced against the remaining whole-call `budget` so a slow or HUNG arbiter cannot
609
+ * outlive the grep `timeout`.
610
+ * @param {CanRead} canRead
611
+ * @param {string} abs
612
+ * @param {boolean} isDir
613
+ * @param {{deadline: number, budgetMs: number}} budget
614
+ * @returns {Promise<boolean>}
615
+ */
616
+ async function askCanRead(canRead, abs, isDir, budget) {
617
+ let verdict;
618
+ /** @type {any} */
619
+ let timer;
620
+ try {
621
+ const remaining = budget.deadline - Date.now();
622
+ const exhausted = () => Object.assign(
623
+ new Error(`shell_grep: canRead walk exceeded ${budget.budgetMs}ms time budget — slow or hung canRead. Narrow the root or raise timeout.`),
624
+ { budgetExceeded: true },
625
+ );
626
+ if (remaining <= 0) throw exhausted();
627
+ verdict = await new Promise((resolve, reject) => {
628
+ // Ref'd on purpose: a pending call must keep the loop alive to fire the budget; cleared in `finally`.
629
+ timer = setTimeout(() => reject(exhausted()), remaining);
630
+ Promise.resolve(canRead(abs, { isDir, tool: 'shell_grep' })).then(resolve, reject);
631
+ });
632
+ } catch (/** @type {any} */ err) {
633
+ if (err instanceof HaltError) throw err;
634
+ if (err && err.budgetExceeded) throw err;
635
+ throw new Error(`shell_grep: canRead threw for ${abs} (${err && err.name ? err.name : typeof err})`);
636
+ } finally {
637
+ clearTimeout(timer);
638
+ }
639
+ if (verdict !== true && verdict !== false) {
640
+ throw new Error(`shell_grep: canRead returned a non-boolean (${verdict === null ? 'null' : typeof verdict}) for ${abs}`);
641
+ }
642
+ return verdict;
643
+ }
644
+
645
+ /**
646
+ * Main-thread, gate-vetted walk for `canRead` mode. Same semantics as `walk` (withFileTypes,
647
+ * symlinks neither dir nor file ⇒ skipped, readdir error ⇒ skip, `recursive:false` honoured) but
648
+ * each directory is checked BEFORE descent (false ⇒ subtree pruned, never listed) and each file
649
+ * BEFORE it is listed. The root is not checked (the caller's policy already gated it).
650
+ * @param {string} dir
651
+ * @param {boolean} recursive
652
+ * @param {CanRead} canRead
653
+ * @param {string[]} out
654
+ * @param {{deadline: number, budgetMs: number}} budget
655
+ */
656
+ async function walkChecked(dir, recursive, canRead, out, budget) {
657
+ let entries;
658
+ try {
659
+ entries = await fs.readdir(dir, { withFileTypes: true });
660
+ } catch {
661
+ return;
662
+ }
663
+ for (const entry of entries) {
664
+ const full = path.join(dir, entry.name);
665
+ if (entry.isDirectory()) {
666
+ if (!recursive) continue;
667
+ if (!(await askCanRead(canRead, full, true, budget))) continue;
668
+ await walkChecked(full, true, canRead, out, budget);
669
+ } else if (entry.isFile()) {
670
+ if (await askCanRead(canRead, full, false, budget)) out.push(full);
671
+ }
672
+ }
673
+ }
674
+
592
675
  /**
593
676
  * The actual search: walk, skip binaries, regex-test each line. Runs in a worker thread (see
594
677
  * grep-worker.js) so a runaway regex is killable via `worker.terminate()`. JS RegExp has no
@@ -596,8 +679,10 @@ function looksCatastrophic(pattern) {
596
679
  * only sound bound (the static `looksCatastrophic` guard is a best-effort fast-reject, not a
597
680
  * guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially).
598
681
  * @param {GrepArgs} args
682
+ * @param {string[]} [providedFiles] - INTERNAL, never from tool args: the `canRead`-vetted file list
683
+ * (passed by grep-worker.js from its own workerData key, set only by `grepPath`).
599
684
  */
600
- async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches, flags = 'i', noFollowSymlinks = false }) {
685
+ async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches, flags = 'i', noFollowSymlinks = false }, providedFiles) {
601
686
  const resolved = resolveToolPath(rawPath);
602
687
  const cap = maxMatches || DEFAULT_GREP_MAX_MATCHES;
603
688
  let re;
@@ -613,7 +698,10 @@ async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches,
613
698
  if (!stat) throw new Error(`shell_grep: path not found — ${rawPath}`);
614
699
 
615
700
  const files = [];
616
- if (stat.isFile()) {
701
+ if (Array.isArray(providedFiles)) {
702
+ // `canRead` mode: the main thread already walked + vetted the tree; read ONLY this list.
703
+ files.push(...providedFiles);
704
+ } else if (stat.isFile()) {
617
705
  files.push(resolved);
618
706
  } else if (stat.isDirectory()) {
619
707
  for await (const f of walk(resolved, recursive)) files.push(f);
@@ -655,8 +743,10 @@ async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches,
655
743
  * worker is even spawned — the ONLY loud/thrown refusal in the grep path (per-file skips during
656
744
  * the walk happen inside the worker and stay silent, matching the existing read-error `continue`).
657
745
  * @param {GrepArgs} args
746
+ * @param {CanRead} [canRead] - opt-in path arbiter (see `createShellTools`); when set the walk runs here
747
+ * on the main thread and the worker receives only the vetted file list.
658
748
  */
659
- async function grepPath(args) {
749
+ async function grepPath(args, canRead) {
660
750
  const { pattern, flags = 'i', timeout, path: rawPath, noFollowSymlinks = false } = args;
661
751
  if (looksCatastrophic(pattern)) {
662
752
  throw new Error(
@@ -696,9 +786,38 @@ async function grepPath(args) {
696
786
  }
697
787
  }
698
788
 
699
- const budgetMs = timeout && timeout > 0 ? timeout : DEFAULT_GREP_TIMEOUT_MS;
789
+ // Explicit allow-list: model-controlled tool args are NEVER spread into the worker payload.
790
+ const { recursive, maxMatches } = args;
791
+ const workerArgs = { pattern, path: rawPath, recursive, maxMatches, flags, timeout, noFollowSymlinks };
792
+ /** @type {string[]|undefined} */
793
+ let vettedFiles;
794
+ // ONE budget for the WHOLE call (canRead walk + worker): the deadline starts before the walk.
795
+ // Node clamps setTimeout delays > 2^31-1 (and NaN/Infinity) to 1ms, so clamp here, at the single source.
796
+ const budgetMs = typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0 ? Math.min(timeout, MAX_TIMER_MS) : DEFAULT_GREP_TIMEOUT_MS;
797
+ const callStart = Date.now();
798
+ const budget = { deadline: Date.now() + budgetMs, budgetMs };
799
+ if (canRead) {
800
+ // The gate cannot cross into the Worker, so the walk runs here. Only a directory root walks; a
801
+ // missing root / file root falls through to the worker unchanged (root is never passed to canRead).
802
+ const root = resolveToolPath(rawPath);
803
+ const st = await fs.stat(root).catch(() => null);
804
+ if (st && st.isDirectory()) {
805
+ /** @type {string[]} */
806
+ const files = [];
807
+ // Same truthiness as the unset path (`recursive = true` default: undefined => true, other falsy => false).
808
+ await walkChecked(root, args.recursive === undefined ? true : !!args.recursive, canRead, files, budget);
809
+ vettedFiles = files;
810
+ }
811
+ }
812
+
813
+ // The worker gets only what is LEFT of the budget; none left => reject without spawning.
814
+ const walkMs = Date.now() - callStart;
815
+ const workerBudgetMs = canRead ? budget.deadline - Date.now() : budgetMs;
816
+ if (workerBudgetMs <= 0) {
817
+ throw new Error(`shell_grep: canRead walk exceeded ${budgetMs}ms time budget — no time left to search. Narrow the root or raise timeout.`);
818
+ }
700
819
  return new Promise((resolve, reject) => {
701
- const worker = new Worker(path.join(__dirname, 'grep-worker.js'), { workerData: args });
820
+ const worker = new Worker(path.join(__dirname, 'grep-worker.js'), { workerData: { args: workerArgs, files: vettedFiles } });
702
821
  let settled = false;
703
822
  const done = (fn, val) => {
704
823
  if (settled) return;
@@ -708,11 +827,13 @@ async function grepPath(args) {
708
827
  fn(val);
709
828
  };
710
829
  const timer = setTimeout(() => {
711
- done(reject, new Error(
712
- `shell_grep: pattern exceeded ${budgetMs}ms time budget — likely catastrophic backtracking. ` +
713
- `Simplify the regex.`,
830
+ done(reject, new Error(canRead
831
+ ? `shell_grep: exceeded ${budgetMs}ms time budget (shared with the canRead walk, which used ${walkMs}ms) — ` +
832
+ `a slow canRead or a catastrophic regex; raise timeout or simplify the regex.`
833
+ : `shell_grep: pattern exceeded ${budgetMs}ms time budget — likely catastrophic backtracking. ` +
834
+ `Simplify the regex.`,
714
835
  ));
715
- }, budgetMs);
836
+ }, workerBudgetMs);
716
837
  timer.unref?.();
717
838
  worker.once('message', (msg) => {
718
839
  if (msg && msg.ok) done(resolve, msg.result);
@@ -818,7 +939,7 @@ function execCommand({ command, cwd, timeout, maxBuffer, env }) {
818
939
  * Create the six shell tools. Configuration is mostly per-call via tool args; gating is the
819
940
  * caller's responsibility via `new Loop({ policy })`.
820
941
  *
821
- * @param {{noFollowSymlinks?: boolean}} [options] - `noFollowSymlinks` (default `false`, byte-
942
+ * @param {{noFollowSymlinks?: boolean, canRead?: CanRead}} [options] - `noFollowSymlinks` (default `false`, byte-
822
943
  * identical to the pre-existing behavior when omitted): when `true`, `shell_read`, `shell_write`,
823
944
  * `shell_edit`, and `shell_grep` refuse to open a path whose FINAL path component is a symlink
824
945
  * (file, dir, or dangling) — THROWN, `err.code:'ELOOP'`, never a silently-followed link. Scope is
@@ -833,6 +954,20 @@ function execCommand({ command, cwd, timeout, maxBuffer, env }) {
833
954
  * not a bug. Set per `createShellTools()` instance, not module-global — safe to mix a
834
955
  * `noFollowSymlinks:true` toolset for one agent alongside a default toolset for another in the same
835
956
  * process.
957
+ * `canRead` (opt-in; unset => byte-identical to before): `async (absPath, {isDir, tool}) => boolean`,
958
+ * consulted by `shell_grep` for EVERY directory (before descending; `false` prunes the whole subtree,
959
+ * never listed) and file (`false` skips it silently) found under a directory root. Closes the
960
+ * root-grep bypass of a gate's `fs.deny`: the Loop policy gates only the ROOT path, so a deny on
961
+ * `<root>/.secret` is invisible to a grep of `<root>`. The root itself is NOT passed (already
962
+ * policy-gated). Wire bareguard as `(p, {tool}) => gate.allows({type:'read', tool, path:p})`. When
963
+ * set, the walk runs on the main thread (the gate can't cross into the Worker) and the worker
964
+ * receives only the allowed list. Fail-closed LOUD: a throw or non-boolean return rejects the grep
965
+ * (type only in the message); `HaltError` propagates as-is. A non-function `canRead` throws
966
+ * `ValidationError` at creation. `shell_read` on a directory lists one dir's names and is gated on
967
+ * its own path — unaffected. The grep `timeout` bounds the WHOLE call, the canRead walk included (a slow or
968
+ * hung `canRead` rejects with a distinct `canRead walk exceeded` error). TOCTOU: `canRead` vets paths at WALK
969
+ * time; a path swapped for a symlink between vetting and the worker's read is followed unless
970
+ * `noFollowSymlinks:true` — pair `canRead` with `noFollowSymlinks:true`.
836
971
  * @returns {{tools: ToolDef[]}}
837
972
  * @when you want to give an agent shell/file tools (read, grep, write, edit, run, exec) — cross-platform, pure Node, zero deps
838
973
  * @fails never throws at creation; gating is the caller's via Loop({ policy }) and fs.writeScope, shell_edit refuses a non-unique anchor as a tool result (file untouched), and with noFollowSymlinks:true the four file tools throw ELOOP on a symlinked final path component.
@@ -842,7 +977,10 @@ function execCommand({ command, cwd, timeout, maxBuffer, env }) {
842
977
  * const { tools: safeTools } = createShellTools({ noFollowSymlinks: true });
843
978
  */
844
979
  function createShellTools(options = {}) {
845
- const { noFollowSymlinks = false } = options;
980
+ const { noFollowSymlinks = false, canRead } = options;
981
+ if (canRead !== undefined && typeof canRead !== 'function') {
982
+ throw new ValidationError(`createShellTools: canRead must be a function (got ${canRead === null ? 'null' : typeof canRead})`);
983
+ }
846
984
  /** @type {ToolDef[]} */
847
985
  const tools = [
848
986
  {
@@ -873,7 +1011,7 @@ function createShellTools(options = {}) {
873
1011
  },
874
1012
  required: ['pattern', 'path'],
875
1013
  },
876
- execute: async (/** @type {GrepArgs} */ args) => grepPath({ ...args, noFollowSymlinks }),
1014
+ execute: async (/** @type {GrepArgs} */ args) => grepPath({ ...args, noFollowSymlinks }, canRead),
877
1015
  },
878
1016
  {
879
1017
  name: 'shell_write',