bare-agent 0.46.5 → 0.47.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/tools/shell.js CHANGED
@@ -19,27 +19,56 @@
19
19
  * `fs`/`bash` primitives — those need `action.type ∈ {read,write,edit,bash}` with `action.path`/`action.cmd`.
20
20
  * To gate `shell_write`/`shell_edit` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it
21
21
  * touches disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
22
- * mapping (`shell_write` → `{ type:'write', path }`, `shell_edit` → `{ type:'edit', path }`, `shell_read`/`shell_grep`
23
- * → `{ type:'read', path }`, `shell_run`/`shell_exec` → `{ type:'bash', cmd }`). bareguard gates `edit` by
22
+ * mapping (`shell_write` → `{ type:'write', path: resolveToolPath(path) }`, `shell_edit` → `{ type:'edit', path: resolveToolPath(path) }`,
23
+ * `shell_read`/`shell_grep` → `{ type:'read', path: resolveToolPath(path) }`, `shell_run`/`shell_exec` → `{ type:'bash', cmd }`) —
24
+ * canonicalize with `resolveToolPath` (exported alongside `createShellTools`) before the gate check, so the string
25
+ * the gate judges is the string the tool actually opens (see the CAVEAT below). bareguard gates `edit` by
24
26
  * `fs.writeScope` identically to `write` (its FS primitive's `FS_TYPES` includes `edit`), so a consumer that
25
27
  * fences `write` gets `edit` fenced by the same scope with ZERO extra config. A write/edit tool alone is NOT
26
28
  * auto-gated — validated by poc/ba2-write-tool-gate.mjs (without the translator the out-of-scope write leaks).
27
29
  *
28
- * CAVEAT (applies to read AND write scopes): bareguard's `fs` primitive matches paths LEXICALLY (no
29
- * `realpath`/symlink resolution), so a symlink that lives INSIDE the allowed scope but points OUTSIDE it is
30
- * not caught — a `shell_write` through such a link can escape the scope. If untrusted input can create
31
- * symlinks under your scope, canonicalize (`fs.realpath`) before the gate, or keep the scope on a root with
32
- * no attacker-writable symlinks. This is bareguard's documented lexical-match contract, not specific to this tool.
30
+ * CAVEAT (applies to read AND write scopes): bareguard's `fs` primitive matches paths LEXICALLY TODAY
31
+ * (no symlink resolution) — a resolved-path (symlink) containment check is planned for an upcoming
32
+ * bareguard release, not yet published; until it ships, a symlinked PARENT directory is NOT contained
33
+ * even when the link itself sits inside an allowed scope. bare-agent canonicalizes the path (`~` +
34
+ * resolve, via `resolveToolPath`) before the gate check and opens that same path — this closes a
35
+ * DIFFERENT gap (the gate judging a `~`/relative path in a different form than the one the tool
36
+ * actually opens), not the lexical-match gap above. `noFollowSymlinks` refuses a symlinked FINAL path
37
+ * component at open time (opt-in, below). None of this covers hardlinks.
38
+ *
39
+ * OPT-IN SYMLINK REFUSAL: `createShellTools({ noFollowSymlinks: true })` closes the specific case above
40
+ * where the symlink itself is the FINAL path component the tool opens (`shell_read`/`shell_write`/`shell_edit`
41
+ * on the path directly, `shell_grep`'s root and per-file reads) — that open now refuses (throws, `ELOOP`)
42
+ * instead of following the link, whether it points inside or outside any configured scope. Three things
43
+ * this does NOT cover, by design: (1) a symlinked PARENT directory (e.g. `/scope/linked-dir/file.txt`
44
+ * where `linked-dir` itself is the link) — that's the lexical-match gap above, unrelated to this flag;
45
+ * (2) on Windows, where `fs.constants.O_NOFOLLOW` doesn't exist, the check falls back to a non-atomic
46
+ * `fs.lstat`-then-open — a symlink planted in the gap between the two calls slips through; and (3) the
47
+ * `cwd` option of `shell_run`/`shell_exec` — it only gets `~` expansion, never `noFollowSymlinks` or
48
+ * `resolveToolPath`'s full canonicalization, so a symlinked `cwd` is followed. Default is `false`
49
+ * (identical behavior to before this option existed).
50
+ *
51
+ * DIRECTORY-LISTING RACE (narrowed, not closed): a directory listing (`shell_read` on a dir,
52
+ * `shell_grep`'s root when it's a dir) opens by FD then still has to list by PATH — Node's
53
+ * `fs/promises` has no `fdopendir` — so a directory swapped in at that path between the open and the
54
+ * listing could report a DIFFERENT directory's NAMES (never contents; every content read goes through
55
+ * the already-open fd, never re-opened by path). With `noFollowSymlinks` on, a `dev`+`ino` recheck
56
+ * runs immediately after the listing and refuses (`ELOOP`) on a mismatch — this narrows the window to
57
+ * essentially nothing but does not close it: a swap-and-swap-back that lands on the original `dev`+
58
+ * `ino` before the recheck runs is undetectable by any check that must re-consult the path.
33
59
  */
34
60
 
35
61
  /** @typedef {import('../types').ToolDef} ToolDef */
36
62
 
37
63
  const fs = require('node:fs/promises');
38
64
  const path = require('node:path');
65
+ const os = require('node:os');
39
66
  const crypto = require('node:crypto');
40
67
  const { exec, execFile } = require('node:child_process');
41
68
  const { Worker } = require('node:worker_threads');
42
69
 
70
+ const fsConstants = fs.constants;
71
+
43
72
  const DEFAULT_READ_MAX_BYTES = 256 * 1024; // 256 KB
44
73
  const DEFAULT_WRITE_MAX_BYTES = 5 * 1024 * 1024; // 5 MB — a sanity ceiling on a single write (LLM-authored)
45
74
  const DEFAULT_GREP_MAX_MATCHES = 200;
@@ -48,45 +77,256 @@ const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
48
77
  const DEFAULT_EXEC_MAX_BUFFER = 1024 * 1024; // 1 MB
49
78
 
50
79
  /**
80
+ * Type-only description of a bad path arg for an error message — NEVER the value itself (repo rule:
81
+ * an unexpected value must never leak into an error/audit log, only its type/category). `p` here is
82
+ * untrusted, model-authored tool-call input, so this is the boundary that rule protects.
83
+ * @param {unknown} p
84
+ * @returns {string}
85
+ */
86
+ function describeBadPath(p) {
87
+ if (p === undefined) return 'undefined';
88
+ if (p === null) return 'null';
89
+ if (p === '') return 'empty string';
90
+ return typeof p;
91
+ }
92
+
93
+ /**
94
+ * Expand a leading `~`/`~/…` to the real home directory via `os.homedir()` — matching how bareguard's
95
+ * own config-side `~` expansion works, so the SAME string means the same absolute path on both sides
96
+ * of a gate check. Throws a clear error rather than ever silently degrading `~/x` to `/x`:
97
+ * the previous `process.env.HOME || process.env.USERPROFILE || ''` fallback did exactly that when
98
+ * `HOME` was unset or empty — a `~`-rooted path would resolve to the FILESYSTEM ROOT, not "no home,"
99
+ * an under-modeled-boundary bug (an ambiguous case rounding toward "works" instead of surfacing).
51
100
  * @param {string} p
101
+ * @param {() => string} [homedirFn] - Injectable override, used ONLY by tests to force the
102
+ * empty/throwing-homedir case deterministically, without monkeypatching `os.homedir` globally
103
+ * (which would affect every other test in this process). Production call sites never pass this.
52
104
  * @returns {string}
53
105
  */
54
- function expandHome(p) {
106
+ function expandHome(p, homedirFn = os.homedir) {
107
+ if (typeof p !== 'string') {
108
+ throw new Error(`expandHome: path must be a string (got ${describeBadPath(p)})`);
109
+ }
55
110
  if (!p) return p;
56
111
  if (p.startsWith('~/') || p === '~') {
57
- const home = process.env.HOME || process.env.USERPROFILE || '';
112
+ let home;
113
+ try {
114
+ home = homedirFn();
115
+ } catch (/** @type {any} */ err) {
116
+ throw new Error(`cannot expand ~: no home directory (${err && err.message ? err.message : String(err)})`);
117
+ }
118
+ if (!home) {
119
+ throw new Error('cannot expand ~: no home directory');
120
+ }
58
121
  return path.join(home, p.slice(1));
59
122
  }
60
123
  return p;
61
124
  }
62
125
 
126
+ /**
127
+ * THE canonicalizer every shell file tool uses before opening a path — and the same canonicalization
128
+ * an adopter should apply before `gate.check` so the string it judges is the string that gets opened
129
+ * (see the top-of-file CAVEAT). Expands `~`/`~/…` via `os.homedir()` (throwing rather than silently
130
+ * degrading — see `expandHome`), then resolves against `process.cwd()` — matching how bareguard's own
131
+ * config-side path handling expects `~` and relative paths to be canonicalized before it judges them.
132
+ *
133
+ * Idempotent: `resolveToolPath(resolveToolPath(p)) === resolveToolPath(p)` — an already-absolute,
134
+ * already-`~`-free path passed back in is returned unchanged (`path.resolve` on an absolute path is
135
+ * a no-op; there is no `~` left to expand).
136
+ * REJECTS a non-string or empty `p` outright (thrown, before any expansion/resolution runs) —
137
+ * every file tool routes through this function, so a model-authored call that omits `path` or sends
138
+ * `""` must not silently resolve to `process.cwd()` (an assumed default a gate would judge and a tool
139
+ * would then read/list, for input that named no path at all). The rejection message names only the
140
+ * TYPE/category of the bad value (`describeBadPath`), never the value itself.
141
+ * @param {string} p
142
+ * @returns {string}
143
+ * @when you gate shell file tools — canonicalize the tool's path once with this, put it in the action you pass to `gate.check`, so the gate judges exactly the path the tool opens
144
+ * @fails throws on a non-string or empty path, and on a `~` path when no home directory can be determined — never turns `~/x` into `/x` or an empty path into cwd
145
+ * @example
146
+ * const resolved = resolveToolPath('~/notes.txt');
147
+ */
148
+ function resolveToolPath(p) {
149
+ if (typeof p !== 'string' || p.length === 0) {
150
+ throw new Error(`resolveToolPath: path must be a non-empty string (got ${describeBadPath(p)})`);
151
+ }
152
+ return path.resolve(expandHome(p));
153
+ }
154
+
155
+ /**
156
+ * Build the standard refusal thrown by every symlink-refusing site (BA-nofollow). Deliberately a
157
+ * plain `Error` (not a subclass) so it crosses the Loop's tool-error boundary like any other tool
158
+ * failure and feeds the BA-12 identical-error spin guard; `code:'ELOOP'` mirrors the kernel errno
159
+ * that `O_NOFOLLOW` itself raises, so callers can switch on ONE field regardless of which of the
160
+ * two code paths (native flag vs the Windows lstat fallback) produced it.
161
+ * @param {string} toolName
162
+ * @param {string} resolvedPath
163
+ * @returns {Error}
164
+ */
165
+ function symlinkRefusalError(toolName, resolvedPath) {
166
+ const err = /** @type {any} */ (new Error(
167
+ `${toolName}: refusing to follow symlink at ${resolvedPath} (noFollowSymlinks is on)`,
168
+ ));
169
+ err.code = 'ELOOP';
170
+ return err;
171
+ }
172
+
173
+ /**
174
+ * ONE shared open path for every file-open site in this module (`shell_read`, `shell_write`,
175
+ * `shell_edit`, and `shell_grep`'s root check + per-file reads). With `noFollowSymlinks` off this
176
+ * is a plain `fs.open` — the caller's `flags` decide read/write/create semantics, byte-identical to
177
+ * this module's pre-existing behavior. With it on, refuses to follow a symlink at the FINAL path
178
+ * component (opt-in — never covers a symlinked PARENT directory; see the top-of-file CAVEAT for
179
+ * bareguard's resolved-path containment side of that, upcoming but not yet published).
180
+ *
181
+ * Two implementations when the flag is on, selected by whether the platform exposes `O_NOFOLLOW`
182
+ * (present on linux/macOS, `undefined` on Windows):
183
+ * - Native: pass `O_NOFOLLOW` straight to the kernel via `fs.open`. Atomic — there is no window
184
+ * between checking and opening. A symlink at the final component (file, dir, or DANGLING)
185
+ * makes the open fail with `ELOOP` before anything is created or read, which we translate to
186
+ * `symlinkRefusalError`. Any other error (ENOENT, EACCES, …) passes through unchanged.
187
+ * - Fallback (`O_NOFOLLOW` undefined): `fs.lstat` the path first and refuse if it is a symlink,
188
+ * then `fs.open` normally. This is NOT atomic — a symlink planted between the `lstat` and the
189
+ * `open` slips through (TOCTOU) — documented as a known Windows limitation, not a bug: Windows
190
+ * has no `O_NOFOLLOW`-equivalent open flag reachable from Node. `lstat` throwing `ENOENT` means
191
+ * "nothing there yet," which is fine for a write/create call — the open below still runs and
192
+ * either creates the file or throws its own ENOENT for a read.
193
+ * @param {string} toolName
194
+ * @param {string} resolvedPath
195
+ * @param {number} flags
196
+ * @param {number} [mode]
197
+ * @param {{noFollowSymlinks?: boolean, constants?: {O_NOFOLLOW?: number}}} [options] - `constants` is
198
+ * an injectable override, used by tests to force the Windows fallback branch on a non-Windows CI
199
+ * box without monkeypatching `fs.constants` globally.
200
+ */
201
+ async function openFile(toolName, resolvedPath, flags, mode, options = {}) {
202
+ const { noFollowSymlinks = false, constants = fsConstants } = options;
203
+ if (!noFollowSymlinks) {
204
+ return fs.open(resolvedPath, flags, mode);
205
+ }
206
+ const oNoFollow = constants.O_NOFOLLOW;
207
+ if (typeof oNoFollow === 'number') {
208
+ try {
209
+ return await fs.open(resolvedPath, flags | oNoFollow, mode);
210
+ } catch (/** @type {any} */ err) {
211
+ if (err && err.code === 'ELOOP') throw symlinkRefusalError(toolName, resolvedPath);
212
+ throw err;
213
+ }
214
+ }
215
+ // Windows fallback: non-atomic lstat-then-open.
216
+ let lst = null;
217
+ try {
218
+ lst = await fs.lstat(resolvedPath);
219
+ } catch (/** @type {any} */ err) {
220
+ if (err && err.code !== 'ENOENT') throw err;
221
+ }
222
+ if (lst && lst.isSymbolicLink()) throw symlinkRefusalError(toolName, resolvedPath);
223
+ return fs.open(resolvedPath, flags, mode);
224
+ }
225
+
226
+ /**
227
+ * Narrows (does not close) the directory-listing race: after opening a directory by FD we still
228
+ * have to list it by PATH — Node's `fs/promises` exposes no `fdopendir` — so a directory or symlink
229
+ * swapped in at that path between the open and the `readdir` could make us list a DIFFERENT
230
+ * directory's NAMES. This never leaks file CONTENTS (every content read in this module goes through
231
+ * the already-open fd, never re-opened by path), only which names are reported for a listing.
232
+ *
233
+ * Re-`lstat`s the path right after the read and refuses (throws, `ELOOP`) unless its `dev`+`ino`
234
+ * still match the handle that was opened — catching both a swap-to-a-symlink (whose own inode won't
235
+ * match the real directory's) and a swap-to-a-different-real-directory (a different inode).
236
+ *
237
+ * RESIDUAL, documented not closed: a swap-and-swap-back that lands back on the original dev+ino
238
+ * before this check runs is undetectable — any check that must re-consult the path shares this
239
+ * limit. Only called when `noFollowSymlinks` is on; the recheck is inert otherwise.
240
+ * @param {string} toolName
241
+ * @param {string} resolvedPath
242
+ * @param {import('node:fs').Stats} handleStat
243
+ */
244
+ async function assertDirStillMatchesHandle(toolName, resolvedPath, handleStat) {
245
+ const current = await fs.lstat(resolvedPath).catch(() => null);
246
+ if (!current || current.dev !== handleStat.dev || current.ino !== handleStat.ino) {
247
+ throw symlinkRefusalError(toolName, resolvedPath);
248
+ }
249
+ }
250
+
251
+ const READ_BOUNDED_CHUNK_SIZE = 64 * 1024;
252
+
253
+ /**
254
+ * Read from an open file handle in chunks, stopping at EOF or once `limit + 1` bytes have been
255
+ * read — NEVER more, regardless of what `stat.size` claims. This is the fix for a real OOM: a
256
+ * device file like `/dev/zero` has no EOF at all, so an unbounded `fh.readFile()` grows without
257
+ * bound until the process dies (observed: `shell_read({path:'/dev/zero'})` killed the whole agent
258
+ * with a FATAL heap OOM, exit 134). A `stat.size`-lying file (e.g. `/proc/kallsyms`, `size:0` but
259
+ * actually ~20MB) is a lesser version of the same problem — fully read then discarded down to the
260
+ * cap. `readBounded` reads AT MOST `limit + 1` bytes either way: the `+1` exists only so the caller
261
+ * can distinguish "exactly `limit` bytes, then real EOF" (`hitLimit:false`) from "there was more"
262
+ * (`hitLimit:true`) without a second read.
263
+ *
264
+ * Reads at `position: null` (the fd's own advancing cursor, not a caller-tracked offset) — this is
265
+ * what makes it correct uniformly across regular files, procfs/sysfs (offset-readable despite a
266
+ * lying size), and character devices (which generally ignore any offset argument regardless).
267
+ * @param {import('node:fs/promises').FileHandle} fh
268
+ * @param {number} limit
269
+ * @returns {Promise<{buf: Buffer, hitLimit: boolean}>}
270
+ */
271
+ async function readBounded(fh, limit) {
272
+ const chunks = [];
273
+ let total = 0;
274
+ while (total <= limit) {
275
+ const wantBytes = Math.min(READ_BOUNDED_CHUNK_SIZE, limit + 1 - total);
276
+ const chunk = Buffer.alloc(wantBytes);
277
+ const { bytesRead } = await fh.read(chunk, 0, wantBytes, null);
278
+ if (bytesRead === 0) break; // real EOF
279
+ chunks.push(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead));
280
+ total += bytesRead;
281
+ }
282
+ return { buf: Buffer.concat(chunks, total), hitLimit: total > limit };
283
+ }
284
+
63
285
  /**
64
286
  * @param {string} rawPath
65
287
  * @param {number} [maxBytes]
288
+ * @param {{noFollowSymlinks?: boolean}} [options]
66
289
  */
67
- async function readEntry(rawPath, maxBytes) {
68
- const resolved = path.resolve(expandHome(rawPath));
69
- const stat = await fs.stat(resolved);
70
- if (stat.isDirectory()) {
71
- const entries = await fs.readdir(resolved, { withFileTypes: true });
72
- const lines = entries.map(e => {
73
- const kind = e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'link' : 'file';
74
- return `${kind}\t${e.name}`;
75
- });
76
- return `dir ${resolved}\n${lines.join('\n')}`;
77
- }
290
+ async function readEntry(rawPath, maxBytes, options = {}) {
291
+ const resolved = resolveToolPath(rawPath);
78
292
  const cap = maxBytes || DEFAULT_READ_MAX_BYTES;
79
- if (stat.size > cap) {
80
- const fh = await fs.open(resolved, 'r');
81
- try {
293
+
294
+ const fh = await openFile('shell_read', resolved, fsConstants.O_RDONLY, undefined, options);
295
+ try {
296
+ const stat = await fh.stat();
297
+ if (stat.isDirectory()) {
298
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
299
+ // Directory-listing race (item 3): the listing above still goes by PATH, not the fd we just
300
+ // opened (no fdopendir in fs/promises) — recheck right after, narrowing the swap window.
301
+ if (options.noFollowSymlinks) await assertDirStillMatchesHandle('shell_read', resolved, stat);
302
+ const lines = entries.map(e => {
303
+ const kind = e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'link' : 'file';
304
+ return `${kind}\t${e.name}`;
305
+ });
306
+ return `dir ${resolved}\n${lines.join('\n')}`;
307
+ }
308
+ if (stat.size > cap) {
309
+ // Known-large regular file: stat.size is trustworthy here, so a fixed cap-byte read from
310
+ // offset 0 is the fast path — no need to read the whole thing just to discard the tail.
82
311
  const buf = Buffer.alloc(cap);
83
312
  await fh.read(buf, 0, cap, 0);
84
313
  return buf.toString('utf8') + `\n\n[truncated: ${stat.size - cap} more bytes not shown]`;
85
- } finally {
86
- await fh.close();
87
314
  }
315
+ // stat.size is <= cap here, but NOT trustworthy: procfs/sysfs files (e.g. /proc/self/status,
316
+ // /proc/kallsyms) commonly report a size that has nothing to do with their real content, and a
317
+ // device file like /dev/zero has no EOF at all. readBounded caps the read at cap+1 bytes no
318
+ // matter what — never the unbounded `fh.readFile()` this module used to call here (that OOM'd
319
+ // the whole process on /dev/zero, exit 134).
320
+ const { buf, hitLimit } = await readBounded(fh, cap);
321
+ if (hitLimit) {
322
+ // We only know we read cap+1+ bytes, not the real total (stat.size lied) — never invent a
323
+ // "more bytes not shown" count for a size we don't actually know.
324
+ return buf.subarray(0, cap).toString('utf8') + `\n\n[truncated at ${cap} bytes: file size unknown]`;
325
+ }
326
+ return buf.toString('utf8');
327
+ } finally {
328
+ await fh.close().catch(() => {});
88
329
  }
89
- return fs.readFile(resolved, 'utf8');
90
330
  }
91
331
 
92
332
  /**
@@ -106,9 +346,10 @@ async function readEntry(rawPath, maxBytes) {
106
346
  * feeds this UNTRUSTED model-authored args (that is the boundary BA-4 was breached at, and where types buy
107
347
  * nothing).
108
348
  * @param {{path: string, content: string, append?: boolean, maxBytes?: number}} args
349
+ * @param {{noFollowSymlinks?: boolean}} [options]
109
350
  * @returns {Promise<string>}
110
351
  */
111
- async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
352
+ async function writeFile({ path: rawPath, content, append = false, maxBytes }, options = {}) {
112
353
  if (typeof rawPath !== 'string' || rawPath.length === 0) {
113
354
  throw new Error('shell_write requires a non-empty "path" string');
114
355
  }
@@ -124,10 +365,20 @@ async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
124
365
  if (bytes > cap) {
125
366
  throw new Error(`shell_write content is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
126
367
  }
127
- const resolved = path.resolve(expandHome(rawPath));
368
+ const resolved = resolveToolPath(rawPath);
128
369
  await fs.mkdir(path.dirname(resolved), { recursive: true });
129
- if (append) await fs.appendFile(resolved, content, 'utf8');
130
- else await fs.writeFile(resolved, content, 'utf8');
370
+
371
+ // O_TRUNC/O_APPEND + O_CREAT, mode 0o666 (subject to umask) — the same semantics `fs.writeFile`/
372
+ // `fs.appendFile` use under the hood. With `noFollowSymlinks` on, O_NOFOLLOW refuses at the kernel
373
+ // if the final component is a symlink (dangling or not) — the link's target is NEVER created or
374
+ // touched, because the open call fails before that.
375
+ const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | (append ? fsConstants.O_APPEND : fsConstants.O_TRUNC);
376
+ const fh = await openFile('shell_write', resolved, flags, 0o666, options);
377
+ try {
378
+ await fh.writeFile(content, 'utf8');
379
+ } finally {
380
+ await fh.close().catch(() => {});
381
+ }
131
382
  return `${append ? 'appended' : 'wrote'} ${bytes} bytes to ${resolved}`;
132
383
  }
133
384
 
@@ -145,7 +396,9 @@ async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
145
396
  * guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
146
397
  * short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
147
398
  * the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
148
- * - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
399
+ * - fs-layer errors (missing file, a directory), BA-4 param-guard violations, and a SOURCE file larger
400
+ * than the `maxBytes` cap (read bounded via `readBounded`, never fully loaded to find out) THROW at
401
+ * the tool boundary, before any edit is computed — no change made.
149
402
  *
150
403
  * BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
151
404
  * `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
@@ -161,9 +414,10 @@ async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
161
414
  * `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
162
415
  * `newText` lands verbatim.
163
416
  * @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
417
+ * @param {{noFollowSymlinks?: boolean}} [options]
164
418
  * @returns {Promise<string>}
165
419
  */
166
- async function editFile({ path: rawPath, oldText, newText, maxBytes }) {
420
+ async function editFile({ path: rawPath, oldText, newText, maxBytes }, options = {}) {
167
421
  if (typeof rawPath !== 'string' || rawPath.length === 0) {
168
422
  throw new Error('shell_edit requires a non-empty "path" string');
169
423
  }
@@ -181,9 +435,31 @@ async function editFile({ path: rawPath, oldText, newText, maxBytes }) {
181
435
  );
182
436
  }
183
437
 
184
- const resolved = path.resolve(expandHome(rawPath));
438
+ const resolved = resolveToolPath(rawPath);
439
+ // Hoisted above the read (previously computed only after) — readBounded needs a limit up front.
440
+ const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
441
+
442
+ // The read (and, when noFollowSymlinks is on, the symlink refusal) happens BEFORE any temp file
443
+ // or rename — a refusal must leave both the target and a would-be link's target untouched.
185
444
  // fs-layer errors (ENOENT for a missing file, EISDIR for a directory) throw — same surface as shell_read.
186
- const content = await fs.readFile(resolved, 'utf8');
445
+ let content;
446
+ let stat;
447
+ const fh = await openFile('shell_edit', resolved, fsConstants.O_RDONLY, undefined, options);
448
+ try {
449
+ stat = await fh.stat();
450
+ // Bounded read, same reasoning as readEntry: stat.size is untrustworthy (procfs/sysfs) and a
451
+ // device file like /dev/zero has no EOF at all — an unbounded fh.readFile() here OOM'd the
452
+ // whole process (exit 134). `stat` is still kept, for the mode preserved on the rewritten temp
453
+ // file below. Capped at the SAME cap the patched-result size is checked against below — a file
454
+ // already at or over the cap can never produce an in-cap edit anyway.
455
+ const { buf, hitLimit } = await readBounded(fh, cap);
456
+ if (hitLimit) {
457
+ throw new Error(`shell_edit: file is larger than the ${cap}-byte cap (pass maxBytes to raise it) — no change made`);
458
+ }
459
+ content = buf.toString('utf8');
460
+ } finally {
461
+ await fh.close().catch(() => {});
462
+ }
187
463
 
188
464
  // Literal, non-overlapping occurrence count (split on a string does no regex interpretation).
189
465
  const occurrences = content.split(oldText).length - 1;
@@ -199,14 +475,14 @@ async function editFile({ path: rawPath, oldText, newText, maxBytes }) {
199
475
  const idx = content.indexOf(oldText);
200
476
  const patched = content.slice(0, idx) + newText + content.slice(idx + oldText.length);
201
477
 
202
- const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
478
+ // `cap` was hoisted above the read (readBounded needed it); reused here unchanged.
203
479
  const bytes = Buffer.byteLength(patched, 'utf8');
204
480
  if (bytes > cap) {
205
481
  throw new Error(`shell_edit result is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
206
482
  }
207
483
 
208
- // Atomic replace: a sibling temp (same dir → same filesystem → rename is atomic) with the original's mode.
209
- const stat = await fs.stat(resolved);
484
+ // Atomic replace: a sibling temp (same dir → same filesystem → rename is atomic) with the original's
485
+ // mode. `stat` is already the handle's stat from the read above — no second `fs.stat` call needed.
210
486
  const tmp = `${resolved}.shell_edit-${crypto.randomBytes(9).toString('hex')}.tmp`;
211
487
  try {
212
488
  // flag 'wx' (O_CREAT|O_EXCL) — never follow or clobber a pre-planted file/symlink at the temp path; a
@@ -226,11 +502,18 @@ async function editFile({ path: rawPath, oldText, newText, maxBytes }) {
226
502
  return `edited ${resolved}: 1 replacement (-${removed}/+${added} lines)`;
227
503
  }
228
504
 
229
- // Probe the first 1KB for NUL bytes to skip binary files in grep walks.
230
- /** @param {string} filePath */
231
- async function isProbablyText(filePath) {
505
+ // Probe the first 1KB for NUL bytes to skip binary files in grep walks. Per-file grep opens go
506
+ // through the shared `openFile` helper like every other open site; unlike read/write/edit, a
507
+ // symlink refusal here is caught and swallowed (SILENT skip), not surfaced — per the design, only
508
+ // the grep ROOT refusal (in `grepPath`) is a loud tool error. A file that turns into a link between
509
+ // `readdir` and open is skipped like any other read error (ENOENT, EACCES, …) already is.
510
+ /**
511
+ * @param {string} filePath
512
+ * @param {boolean} [noFollowSymlinks]
513
+ */
514
+ async function isProbablyText(filePath, noFollowSymlinks = false) {
232
515
  try {
233
- const fh = await fs.open(filePath, 'r');
516
+ const fh = await openFile('shell_grep', filePath, fsConstants.O_RDONLY, undefined, { noFollowSymlinks });
234
517
  try {
235
518
  const buf = Buffer.alloc(1024);
236
519
  const { bytesRead } = await fh.read(buf, 0, 1024, 0);
@@ -301,6 +584,9 @@ function looksCatastrophic(pattern) {
301
584
  * @property {number} [timeout] - Hard wall-clock ceiling in ms (default 5000). The match runs in a
302
585
  * worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips
303
586
  * past `looksCatastrophic` can no longer hang the host event loop.
587
+ * @property {boolean} [noFollowSymlinks] - When true, refuses a symlinked ROOT path (thrown, loud)
588
+ * and skips (silently, like any other read error) a file that is or becomes a symlink during the
589
+ * walk. Threaded into `workerData` so the worker thread's file reads honor it too.
304
590
  */
305
591
 
306
592
  /**
@@ -311,8 +597,8 @@ function looksCatastrophic(pattern) {
311
597
  * guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially).
312
598
  * @param {GrepArgs} args
313
599
  */
314
- async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches, flags = 'i' }) {
315
- const resolved = path.resolve(expandHome(rawPath));
600
+ async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches, flags = 'i', noFollowSymlinks = false }) {
601
+ const resolved = resolveToolPath(rawPath);
316
602
  const cap = maxMatches || DEFAULT_GREP_MAX_MATCHES;
317
603
  let re;
318
604
  try {
@@ -335,10 +621,15 @@ async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches,
335
621
 
336
622
  for (const file of files) {
337
623
  if (hits.length >= cap) break;
338
- if (!(await isProbablyText(file))) continue;
624
+ if (!(await isProbablyText(file, noFollowSymlinks))) continue;
339
625
  let content;
340
626
  try {
341
- content = await fs.readFile(file, 'utf8');
627
+ const fh = await openFile('shell_grep', file, fsConstants.O_RDONLY, undefined, { noFollowSymlinks });
628
+ try {
629
+ content = (await fh.readFile()).toString('utf8');
630
+ } finally {
631
+ await fh.close();
632
+ }
342
633
  } catch {
343
634
  continue;
344
635
  }
@@ -359,21 +650,50 @@ async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches,
359
650
  * Public grep entry. Fast-rejects obviously catastrophic patterns without paying for a worker,
360
651
  * then runs the search in a worker thread bounded by a hard timeout — so even a pattern that
361
652
  * defeats the static guard degrades to a bounded rejection instead of an event-loop hang.
653
+ *
654
+ * The ROOT symlink refusal (opt-in `noFollowSymlinks`) runs HERE, on the main thread, before the
655
+ * worker is even spawned — the ONLY loud/thrown refusal in the grep path (per-file skips during
656
+ * the walk happen inside the worker and stay silent, matching the existing read-error `continue`).
362
657
  * @param {GrepArgs} args
363
658
  */
364
- function grepPath(args) {
365
- const { pattern, flags = 'i', timeout } = args;
659
+ async function grepPath(args) {
660
+ const { pattern, flags = 'i', timeout, path: rawPath, noFollowSymlinks = false } = args;
366
661
  if (looksCatastrophic(pattern)) {
367
- return Promise.reject(new Error(
662
+ throw new Error(
368
663
  `shell_grep: pattern rejected — nested unbounded quantifier (e.g. "(a+)+") risks catastrophic ` +
369
664
  `backtracking that would block the process. Simplify the regex.`,
370
- ));
665
+ );
371
666
  }
372
667
  // Cheap up-front validation so a syntactically invalid regex fails clearly without a worker spin-up.
373
668
  try {
374
669
  new RegExp(pattern, flags);
375
670
  } catch (/** @type {any} */ err) {
376
- return Promise.reject(new Error(`shell_grep: invalid regex — ${err.message}`));
671
+ throw new Error(`shell_grep: invalid regex — ${err.message}`);
672
+ }
673
+
674
+ if (noFollowSymlinks) {
675
+ // Atomic root check through the SAME shared helper every other open site uses (item 2) — O_RDONLY
676
+ // opens either a file or a directory on Linux/macOS, so one open covers both root shapes. A
677
+ // missing root is left to the worker's own "path not found" error (same message either flag
678
+ // state); any other open failure, including the ELOOP symlink refusal, surfaces here, loud, before
679
+ // the worker is spawned.
680
+ const resolvedRoot = resolveToolPath(rawPath);
681
+ let fh = null;
682
+ try {
683
+ fh = await openFile('shell_grep', resolvedRoot, fsConstants.O_RDONLY, undefined, { noFollowSymlinks: true });
684
+ } catch (/** @type {any} */ err) {
685
+ if (!(err && err.code === 'ENOENT')) throw err;
686
+ }
687
+ if (fh) {
688
+ try {
689
+ const stat = await fh.stat();
690
+ // Directory-listing race (item 3): the worker's own walk() re-reads this root by PATH, so
691
+ // recheck the root's identity here too, narrowing the swap window the same way shell_read does.
692
+ if (stat.isDirectory()) await assertDirStillMatchesHandle('shell_grep', resolvedRoot, stat);
693
+ } finally {
694
+ await fh.close().catch(() => {});
695
+ }
696
+ }
377
697
  }
378
698
 
379
699
  const budgetMs = timeout && timeout > 0 ? timeout : DEFAULT_GREP_TIMEOUT_MS;
@@ -495,17 +815,34 @@ function execCommand({ command, cwd, timeout, maxBuffer, env }) {
495
815
  }
496
816
 
497
817
  /**
498
- * Create the three shell tools. No options — configuration is per-call via tool args,
499
- * gating is the caller's responsibility via `new Loop({ policy })`.
818
+ * Create the six shell tools. Configuration is mostly per-call via tool args; gating is the
819
+ * caller's responsibility via `new Loop({ policy })`.
500
820
  *
821
+ * @param {{noFollowSymlinks?: boolean}} [options] - `noFollowSymlinks` (default `false`, byte-
822
+ * identical to the pre-existing behavior when omitted): when `true`, `shell_read`, `shell_write`,
823
+ * `shell_edit`, and `shell_grep` refuse to open a path whose FINAL path component is a symlink
824
+ * (file, dir, or dangling) — THROWN, `err.code:'ELOOP'`, never a silently-followed link. Scope is
825
+ * the final component ONLY — a symlinked PARENT directory is NOT refused (bareguard's own
826
+ * resolved-path containment check for that is planned for an upcoming release, not yet published;
827
+ * until then the gate matches paths lexically, so a symlinked parent dir is not contained there
828
+ * either — see the top-of-file CAVEAT). The `cwd` option of `shell_run`/`shell_exec` is a separate,
829
+ * NARROWER gap: it only gets `~` expansion, never this flag or `resolveToolPath`'s full
830
+ * canonicalization, so a symlinked `cwd` is always followed. On Windows (`fs.constants.O_NOFOLLOW`
831
+ * is undefined there) the guard falls back to a non-atomic `fs.lstat`-then-open check — a symlink
832
+ * planted in the gap between the two calls slips through; this is a documented platform limitation,
833
+ * not a bug. Set per `createShellTools()` instance, not module-global — safe to mix a
834
+ * `noFollowSymlinks:true` toolset for one agent alongside a default toolset for another in the same
835
+ * process.
501
836
  * @returns {{tools: ToolDef[]}}
502
837
  * @when you want to give an agent shell/file tools (read, grep, write, edit, run, exec) — cross-platform, pure Node, zero deps
503
- * @fails never throws at creation; gating is the caller's via Loop({ policy }) and fs.writeScope, and shell_edit refuses a non-unique anchor as a tool result (file untouched).
838
+ * @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.
504
839
  * @example
505
840
  * const { tools } = createShellTools();
506
841
  * const loop = new Loop({ provider, tools, policy });
842
+ * const { tools: safeTools } = createShellTools({ noFollowSymlinks: true });
507
843
  */
508
- function createShellTools() {
844
+ function createShellTools(options = {}) {
845
+ const { noFollowSymlinks = false } = options;
509
846
  /** @type {ToolDef[]} */
510
847
  const tools = [
511
848
  {
@@ -519,7 +856,8 @@ function createShellTools() {
519
856
  },
520
857
  required: ['path'],
521
858
  },
522
- execute: async (/** @type {{path: string, maxBytes?: number}} */ { path: p, maxBytes }) => readEntry(p, maxBytes),
859
+ execute: async (/** @type {{path: string, maxBytes?: number}} */ { path: p, maxBytes }) =>
860
+ readEntry(p, maxBytes, { noFollowSymlinks }),
523
861
  },
524
862
  {
525
863
  name: 'shell_grep',
@@ -535,7 +873,7 @@ function createShellTools() {
535
873
  },
536
874
  required: ['pattern', 'path'],
537
875
  },
538
- execute: async (/** @type {GrepArgs} */ args) => grepPath(args),
876
+ execute: async (/** @type {GrepArgs} */ args) => grepPath({ ...args, noFollowSymlinks }),
539
877
  },
540
878
  {
541
879
  name: 'shell_write',
@@ -555,7 +893,7 @@ function createShellTools() {
555
893
  // The args are model-authored and UNTRUSTED — `content` may be absent (an output-token-capped
556
894
  // generation), so the boundary type stays loose and `writeFile` enforces the contract at runtime (BA-4).
557
895
  execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) =>
558
- writeFile(/** @type {any} */ (args)),
896
+ writeFile(/** @type {any} */ (args), { noFollowSymlinks }),
559
897
  },
560
898
  {
561
899
  name: 'shell_edit',
@@ -579,7 +917,7 @@ function createShellTools() {
579
917
  // The args are model-authored and UNTRUSTED — oldText/newText may be absent (an output-token-capped
580
918
  // generation), so the boundary type stays loose and editFile enforces the BA-4 contract at runtime.
581
919
  execute: async (/** @type {{path: string, oldText?: string, newText?: string, maxBytes?: number}} */ args) =>
582
- editFile(/** @type {any} */ (args)),
920
+ editFile(/** @type {any} */ (args), { noFollowSymlinks }),
583
921
  },
584
922
  {
585
923
  name: 'shell_run',
@@ -621,4 +959,13 @@ function createShellTools() {
621
959
  return { tools };
622
960
  }
623
961
 
624
- module.exports = { createShellTools, _grepCore, _writeFile: writeFile, _editFile: editFile };
962
+ module.exports = {
963
+ createShellTools,
964
+ resolveToolPath,
965
+ _grepCore,
966
+ _writeFile: writeFile,
967
+ _editFile: editFile,
968
+ _openFile: openFile,
969
+ _assertDirStillMatchesHandle: assertDirStillMatchesHandle,
970
+ _expandHome: expandHome,
971
+ };