bare-agent 0.46.6 → 0.48.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 +23 -4
- package/examples/with-bareguard.mjs +23 -22
- package/package.json +4 -4
- package/primitives.json +13 -4
- package/src/bareguard-adapter.d.ts +33 -8
- package/src/bareguard-adapter.js +98 -15
- package/src/loop.d.ts +6 -2
- package/src/loop.js +2 -2
- package/src/provider-ollama.d.ts +12 -0
- package/src/provider-ollama.js +7 -1
- package/src/provider-openai.d.ts +14 -0
- package/src/provider-openai.js +8 -1
- package/src/provider-toolcalls.d.ts +15 -3
- package/src/provider-toolcalls.js +28 -8
- package/src/retry.d.ts +1 -1
- package/src/retry.js +1 -1
- package/src/tools.d.ts +2 -1
- package/src/tools.js +2 -1
- package/tools/shell.d.ts +136 -6
- package/tools/shell.js +406 -59
- package/types/index.d.ts +6 -1
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 }`,
|
|
23
|
-
* → `{ type:'read', path }`, `shell_run`/`shell_exec` → `{ type:'bash', cmd }`)
|
|
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
|
|
29
|
-
*
|
|
30
|
-
* not
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 =
|
|
368
|
+
const resolved = resolveToolPath(rawPath);
|
|
128
369
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
129
|
-
|
|
130
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
499
|
-
*
|
|
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,
|
|
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 }) =>
|
|
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 = {
|
|
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
|
+
};
|