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.
- package/bareagent.context.md +10 -3
- package/package.json +1 -1
- package/primitives.json +1 -1
- package/src/mcp-bridge.js +67 -9
- package/tools/grep-worker.js +1 -1
- package/tools/shell.d.ts +27 -18
- package/tools/shell.js +151 -13
package/bareagent.context.md
CHANGED
|
@@ -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
|
|
439
|
-
|
|
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
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ') +
|
package/tools/grep-worker.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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:
|
|
713
|
-
|
|
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
|
-
},
|
|
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',
|