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.d.ts CHANGED
@@ -3,9 +3,13 @@ declare namespace _exports {
3
3
  }
4
4
  declare namespace _exports {
5
5
  export { createShellTools };
6
+ export { resolveToolPath };
6
7
  export { _grepCore };
7
8
  export { writeFile as _writeFile };
8
9
  export { editFile as _editFile };
10
+ export { openFile as _openFile };
11
+ export { assertDirStillMatchesHandle as _assertDirStillMatchesHandle };
12
+ export { expandHome as _expandHome };
9
13
  }
10
14
  export = _exports;
11
15
  type GrepArgs = {
@@ -20,6 +24,12 @@ type GrepArgs = {
20
24
  * past `looksCatastrophic` can no longer hang the host event loop.
21
25
  */
22
26
  timeout?: number | undefined;
27
+ /**
28
+ * - When true, refuses a symlinked ROOT path (thrown, loud)
29
+ * and skips (silently, like any other read error) a file that is or becomes a symlink during the
30
+ * walk. Threaded into `workerData` so the worker thread's file reads honor it too.
31
+ */
32
+ noFollowSymlinks?: boolean | undefined;
23
33
  };
24
34
  type RunArgvArgs = {
25
35
  argv: string[];
@@ -37,19 +47,60 @@ type ExecCommandArgs = {
37
47
  };
38
48
  type ToolDef = import("../types").ToolDef;
39
49
  /**
40
- * Create the three shell tools. No options — configuration is per-call via tool args,
41
- * gating is the caller's responsibility via `new Loop({ policy })`.
50
+ * Create the six shell tools. Configuration is mostly per-call via tool args; gating is the
51
+ * caller's responsibility via `new Loop({ policy })`.
42
52
  *
53
+ * @param {{noFollowSymlinks?: boolean}} [options] - `noFollowSymlinks` (default `false`, byte-
54
+ * identical to the pre-existing behavior when omitted): when `true`, `shell_read`, `shell_write`,
55
+ * `shell_edit`, and `shell_grep` refuse to open a path whose FINAL path component is a symlink
56
+ * (file, dir, or dangling) — THROWN, `err.code:'ELOOP'`, never a silently-followed link. Scope is
57
+ * the final component ONLY — a symlinked PARENT directory is NOT refused (bareguard's own
58
+ * resolved-path containment check for that is planned for an upcoming release, not yet published;
59
+ * until then the gate matches paths lexically, so a symlinked parent dir is not contained there
60
+ * either — see the top-of-file CAVEAT). The `cwd` option of `shell_run`/`shell_exec` is a separate,
61
+ * NARROWER gap: it only gets `~` expansion, never this flag or `resolveToolPath`'s full
62
+ * canonicalization, so a symlinked `cwd` is always followed. On Windows (`fs.constants.O_NOFOLLOW`
63
+ * is undefined there) the guard falls back to a non-atomic `fs.lstat`-then-open check — a symlink
64
+ * planted in the gap between the two calls slips through; this is a documented platform limitation,
65
+ * not a bug. Set per `createShellTools()` instance, not module-global — safe to mix a
66
+ * `noFollowSymlinks:true` toolset for one agent alongside a default toolset for another in the same
67
+ * process.
43
68
  * @returns {{tools: ToolDef[]}}
44
69
  * @when you want to give an agent shell/file tools (read, grep, write, edit, run, exec) — cross-platform, pure Node, zero deps
45
- * @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).
70
+ * @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.
46
71
  * @example
47
72
  * const { tools } = createShellTools();
48
73
  * const loop = new Loop({ provider, tools, policy });
74
+ * const { tools: safeTools } = createShellTools({ noFollowSymlinks: true });
49
75
  */
50
- declare function createShellTools(): {
76
+ declare function createShellTools(options?: {
77
+ noFollowSymlinks?: boolean;
78
+ }): {
51
79
  tools: ToolDef[];
52
80
  };
81
+ /**
82
+ * THE canonicalizer every shell file tool uses before opening a path — and the same canonicalization
83
+ * an adopter should apply before `gate.check` so the string it judges is the string that gets opened
84
+ * (see the top-of-file CAVEAT). Expands `~`/`~/…` via `os.homedir()` (throwing rather than silently
85
+ * degrading — see `expandHome`), then resolves against `process.cwd()` — matching how bareguard's own
86
+ * config-side path handling expects `~` and relative paths to be canonicalized before it judges them.
87
+ *
88
+ * Idempotent: `resolveToolPath(resolveToolPath(p)) === resolveToolPath(p)` — an already-absolute,
89
+ * already-`~`-free path passed back in is returned unchanged (`path.resolve` on an absolute path is
90
+ * a no-op; there is no `~` left to expand).
91
+ * REJECTS a non-string or empty `p` outright (thrown, before any expansion/resolution runs) —
92
+ * every file tool routes through this function, so a model-authored call that omits `path` or sends
93
+ * `""` must not silently resolve to `process.cwd()` (an assumed default a gate would judge and a tool
94
+ * would then read/list, for input that named no path at all). The rejection message names only the
95
+ * TYPE/category of the bad value (`describeBadPath`), never the value itself.
96
+ * @param {string} p
97
+ * @returns {string}
98
+ * @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
99
+ * @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
100
+ * @example
101
+ * const resolved = resolveToolPath('~/notes.txt');
102
+ */
103
+ declare function resolveToolPath(p: string): string;
53
104
  /**
54
105
  * @typedef {object} GrepArgs
55
106
  * @property {string} pattern
@@ -60,6 +111,9 @@ declare function createShellTools(): {
60
111
  * @property {number} [timeout] - Hard wall-clock ceiling in ms (default 5000). The match runs in a
61
112
  * worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips
62
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.
63
117
  */
64
118
  /**
65
119
  * The actual search: walk, skip binaries, regex-test each line. Runs in a worker thread (see
@@ -69,7 +123,7 @@ declare function createShellTools(): {
69
123
  * guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially).
70
124
  * @param {GrepArgs} args
71
125
  */
72
- declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags }: GrepArgs): Promise<{
126
+ declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags, noFollowSymlinks }: GrepArgs): Promise<{
73
127
  hits: {
74
128
  file: string;
75
129
  line: number;
@@ -95,6 +149,7 @@ declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flag
95
149
  * feeds this UNTRUSTED model-authored args (that is the boundary BA-4 was breached at, and where types buy
96
150
  * nothing).
97
151
  * @param {{path: string, content: string, append?: boolean, maxBytes?: number}} args
152
+ * @param {{noFollowSymlinks?: boolean}} [options]
98
153
  * @returns {Promise<string>}
99
154
  */
100
155
  declare function writeFile({ path: rawPath, content, append, maxBytes }: {
@@ -102,6 +157,8 @@ declare function writeFile({ path: rawPath, content, append, maxBytes }: {
102
157
  content: string;
103
158
  append?: boolean;
104
159
  maxBytes?: number;
160
+ }, options?: {
161
+ noFollowSymlinks?: boolean;
105
162
  }): Promise<string>;
106
163
  /**
107
164
  * Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
@@ -117,7 +174,9 @@ declare function writeFile({ path: rawPath, content, append, maxBytes }: {
117
174
  * guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
118
175
  * short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
119
176
  * the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
120
- * - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
177
+ * - fs-layer errors (missing file, a directory), BA-4 param-guard violations, and a SOURCE file larger
178
+ * than the `maxBytes` cap (read bounded via `readBounded`, never fully loaded to find out) THROW at
179
+ * the tool boundary, before any edit is computed — no change made.
121
180
  *
122
181
  * BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
123
182
  * `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
@@ -133,6 +192,7 @@ declare function writeFile({ path: rawPath, content, append, maxBytes }: {
133
192
  * `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
134
193
  * `newText` lands verbatim.
135
194
  * @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
195
+ * @param {{noFollowSymlinks?: boolean}} [options]
136
196
  * @returns {Promise<string>}
137
197
  */
138
198
  declare function editFile({ path: rawPath, oldText, newText, maxBytes }: {
@@ -140,4 +200,74 @@ declare function editFile({ path: rawPath, oldText, newText, maxBytes }: {
140
200
  oldText: string;
141
201
  newText: string;
142
202
  maxBytes?: number;
203
+ }, options?: {
204
+ noFollowSymlinks?: boolean;
143
205
  }): Promise<string>;
206
+ /**
207
+ * ONE shared open path for every file-open site in this module (`shell_read`, `shell_write`,
208
+ * `shell_edit`, and `shell_grep`'s root check + per-file reads). With `noFollowSymlinks` off this
209
+ * is a plain `fs.open` — the caller's `flags` decide read/write/create semantics, byte-identical to
210
+ * this module's pre-existing behavior. With it on, refuses to follow a symlink at the FINAL path
211
+ * component (opt-in — never covers a symlinked PARENT directory; see the top-of-file CAVEAT for
212
+ * bareguard's resolved-path containment side of that, upcoming but not yet published).
213
+ *
214
+ * Two implementations when the flag is on, selected by whether the platform exposes `O_NOFOLLOW`
215
+ * (present on linux/macOS, `undefined` on Windows):
216
+ * - Native: pass `O_NOFOLLOW` straight to the kernel via `fs.open`. Atomic — there is no window
217
+ * between checking and opening. A symlink at the final component (file, dir, or DANGLING)
218
+ * makes the open fail with `ELOOP` before anything is created or read, which we translate to
219
+ * `symlinkRefusalError`. Any other error (ENOENT, EACCES, …) passes through unchanged.
220
+ * - Fallback (`O_NOFOLLOW` undefined): `fs.lstat` the path first and refuse if it is a symlink,
221
+ * then `fs.open` normally. This is NOT atomic — a symlink planted between the `lstat` and the
222
+ * `open` slips through (TOCTOU) — documented as a known Windows limitation, not a bug: Windows
223
+ * has no `O_NOFOLLOW`-equivalent open flag reachable from Node. `lstat` throwing `ENOENT` means
224
+ * "nothing there yet," which is fine for a write/create call — the open below still runs and
225
+ * either creates the file or throws its own ENOENT for a read.
226
+ * @param {string} toolName
227
+ * @param {string} resolvedPath
228
+ * @param {number} flags
229
+ * @param {number} [mode]
230
+ * @param {{noFollowSymlinks?: boolean, constants?: {O_NOFOLLOW?: number}}} [options] - `constants` is
231
+ * an injectable override, used by tests to force the Windows fallback branch on a non-Windows CI
232
+ * box without monkeypatching `fs.constants` globally.
233
+ */
234
+ declare function openFile(toolName: string, resolvedPath: string, flags: number, mode?: number, options?: {
235
+ noFollowSymlinks?: boolean;
236
+ constants?: {
237
+ O_NOFOLLOW?: number;
238
+ };
239
+ }): Promise<fs.FileHandle>;
240
+ /**
241
+ * Narrows (does not close) the directory-listing race: after opening a directory by FD we still
242
+ * have to list it by PATH — Node's `fs/promises` exposes no `fdopendir` — so a directory or symlink
243
+ * swapped in at that path between the open and the `readdir` could make us list a DIFFERENT
244
+ * directory's NAMES. This never leaks file CONTENTS (every content read in this module goes through
245
+ * the already-open fd, never re-opened by path), only which names are reported for a listing.
246
+ *
247
+ * Re-`lstat`s the path right after the read and refuses (throws, `ELOOP`) unless its `dev`+`ino`
248
+ * still match the handle that was opened — catching both a swap-to-a-symlink (whose own inode won't
249
+ * match the real directory's) and a swap-to-a-different-real-directory (a different inode).
250
+ *
251
+ * RESIDUAL, documented not closed: a swap-and-swap-back that lands back on the original dev+ino
252
+ * before this check runs is undetectable — any check that must re-consult the path shares this
253
+ * limit. Only called when `noFollowSymlinks` is on; the recheck is inert otherwise.
254
+ * @param {string} toolName
255
+ * @param {string} resolvedPath
256
+ * @param {import('node:fs').Stats} handleStat
257
+ */
258
+ declare function assertDirStillMatchesHandle(toolName: string, resolvedPath: string, handleStat: import("node:fs").Stats): Promise<void>;
259
+ /**
260
+ * Expand a leading `~`/`~/…` to the real home directory via `os.homedir()` — matching how bareguard's
261
+ * own config-side `~` expansion works, so the SAME string means the same absolute path on both sides
262
+ * of a gate check. Throws a clear error rather than ever silently degrading `~/x` to `/x`:
263
+ * the previous `process.env.HOME || process.env.USERPROFILE || ''` fallback did exactly that when
264
+ * `HOME` was unset or empty — a `~`-rooted path would resolve to the FILESYSTEM ROOT, not "no home,"
265
+ * an under-modeled-boundary bug (an ambiguous case rounding toward "works" instead of surfacing).
266
+ * @param {string} p
267
+ * @param {() => string} [homedirFn] - Injectable override, used ONLY by tests to force the
268
+ * empty/throwing-homedir case deterministically, without monkeypatching `os.homedir` globally
269
+ * (which would affect every other test in this process). Production call sites never pass this.
270
+ * @returns {string}
271
+ */
272
+ declare function expandHome(p: string, homedirFn?: () => string): string;
273
+ import fs = require("node:fs/promises");