declare namespace _exports { export { GrepArgs, RunArgvArgs, ExecCommandArgs, ToolDef }; } declare namespace _exports { export { createShellTools }; export { resolveToolPath }; export { _grepCore }; export { writeFile as _writeFile }; export { editFile as _editFile }; export { openFile as _openFile }; export { assertDirStillMatchesHandle as _assertDirStillMatchesHandle }; export { expandHome as _expandHome }; } export = _exports; type GrepArgs = { pattern: string; path: string; recursive?: boolean | undefined; maxMatches?: number | undefined; flags?: string | undefined; /** * - Hard wall-clock ceiling in ms (default 5000). The match runs in a * worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips * past `looksCatastrophic` can no longer hang the host event loop. */ timeout?: number | undefined; /** * - When true, refuses a symlinked ROOT path (thrown, loud) * and skips (silently, like any other read error) a file that is or becomes a symlink during the * walk. Threaded into `workerData` so the worker thread's file reads honor it too. */ noFollowSymlinks?: boolean | undefined; }; type RunArgvArgs = { argv: string[]; cwd?: string | undefined; timeout?: number | undefined; maxBuffer?: number | undefined; env?: Record | undefined; }; type ExecCommandArgs = { command: string; cwd?: string | undefined; timeout?: number | undefined; maxBuffer?: number | undefined; env?: Record | undefined; }; type ToolDef = import("../types").ToolDef; /** * Create the six shell tools. Configuration is mostly per-call via tool args; gating is the * caller's responsibility via `new Loop({ policy })`. * * @param {{noFollowSymlinks?: boolean}} [options] - `noFollowSymlinks` (default `false`, byte- * identical to the pre-existing behavior when omitted): when `true`, `shell_read`, `shell_write`, * `shell_edit`, and `shell_grep` refuse to open a path whose FINAL path component is a symlink * (file, dir, or dangling) — THROWN, `err.code:'ELOOP'`, never a silently-followed link. Scope is * the final component ONLY — a symlinked PARENT directory is NOT refused (bareguard's own * resolved-path containment check for that is planned for an upcoming release, not yet published; * until then the gate matches paths lexically, so a symlinked parent dir is not contained there * either — see the top-of-file CAVEAT). The `cwd` option of `shell_run`/`shell_exec` is a separate, * NARROWER gap: it only gets `~` expansion, never this flag or `resolveToolPath`'s full * canonicalization, so a symlinked `cwd` is always followed. On Windows (`fs.constants.O_NOFOLLOW` * is undefined there) the guard falls back to a non-atomic `fs.lstat`-then-open check — a symlink * planted in the gap between the two calls slips through; this is a documented platform limitation, * not a bug. Set per `createShellTools()` instance, not module-global — safe to mix a * `noFollowSymlinks:true` toolset for one agent alongside a default toolset for another in the same * process. * @returns {{tools: ToolDef[]}} * @when you want to give an agent shell/file tools (read, grep, write, edit, run, exec) — cross-platform, pure Node, zero deps * @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. * @example * const { tools } = createShellTools(); * const loop = new Loop({ provider, tools, policy }); * const { tools: safeTools } = createShellTools({ noFollowSymlinks: true }); */ declare function createShellTools(options?: { noFollowSymlinks?: boolean; }): { tools: ToolDef[]; }; /** * THE canonicalizer every shell file tool uses before opening a path — and the same canonicalization * an adopter should apply before `gate.check` so the string it judges is the string that gets opened * (see the top-of-file CAVEAT). Expands `~`/`~/…` via `os.homedir()` (throwing rather than silently * degrading — see `expandHome`), then resolves against `process.cwd()` — matching how bareguard's own * config-side path handling expects `~` and relative paths to be canonicalized before it judges them. * * Idempotent: `resolveToolPath(resolveToolPath(p)) === resolveToolPath(p)` — an already-absolute, * already-`~`-free path passed back in is returned unchanged (`path.resolve` on an absolute path is * a no-op; there is no `~` left to expand). * REJECTS a non-string or empty `p` outright (thrown, before any expansion/resolution runs) — * every file tool routes through this function, so a model-authored call that omits `path` or sends * `""` must not silently resolve to `process.cwd()` (an assumed default a gate would judge and a tool * would then read/list, for input that named no path at all). The rejection message names only the * TYPE/category of the bad value (`describeBadPath`), never the value itself. * @param {string} p * @returns {string} * @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 * @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 * @example * const resolved = resolveToolPath('~/notes.txt'); */ declare function resolveToolPath(p: string): string; /** * @typedef {object} GrepArgs * @property {string} pattern * @property {string} path * @property {boolean} [recursive] * @property {number} [maxMatches] * @property {string} [flags] * @property {number} [timeout] - Hard wall-clock ceiling in ms (default 5000). The match runs in a * worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips * past `looksCatastrophic` can no longer hang the host event loop. * @property {boolean} [noFollowSymlinks] - When true, refuses a symlinked ROOT path (thrown, loud) * and skips (silently, like any other read error) a file that is or becomes a symlink during the * walk. Threaded into `workerData` so the worker thread's file reads honor it too. */ /** * The actual search: walk, skip binaries, regex-test each line. Runs in a worker thread (see * grep-worker.js) so a runaway regex is killable via `worker.terminate()`. JS RegExp has no * execution timeout and backtracking is uninterruptible on its own thread — isolation is the * only sound bound (the static `looksCatastrophic` guard is a best-effort fast-reject, not a * guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially). * @param {GrepArgs} args */ declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags, noFollowSymlinks }: GrepArgs): Promise<{ hits: { file: string; line: number; text: string; }[]; truncated: boolean; fileCount: number; }>; /** * Write text to a file (the BA-2 first-class write primitive — a coding agent must edit files, and routing * writes through the shell is impractical: redirection is a shell metachar that an argv/bash allowlist denies). * Creates parent directories. Caps size as a sanity ceiling. NO shell — so it gates cleanly through bareguard's * fs primitive when the adopter translates `shell_write` → `{ type:'write', path }` (see createShellTools doc). * * `content` is REQUIRED and must be a string (BA-4). It used to default to `''`, which made the ordinary * failure mode of a long generation — the model hits its output-token cap and the tool call arrives with * `content` absent — silently truncate the target to zero bytes and report `"wrote 0 bytes"` as SUCCESS. * No policy can catch that: a 0-byte write is a legal write, and bareguard's fs primitive judges * `{type:'write', path}` without ever inspecting the body. It is a missing precondition in the primitive, * not a governance gap. An explicit `content: ''` still empties the file — the caller meant it. * `content` is typed REQUIRED so the generated `.d.ts` states the real contract — a library caller that omits * it is a type error, not a runtime surprise. The guard below still runs, because the tool-execute boundary * feeds this UNTRUSTED model-authored args (that is the boundary BA-4 was breached at, and where types buy * nothing). * @param {{path: string, content: string, append?: boolean, maxBytes?: number}} args * @param {{noFollowSymlinks?: boolean}} [options] * @returns {Promise} */ declare function writeFile({ path: rawPath, content, append, maxBytes }: { path: string; content: string; append?: boolean; maxBytes?: number; }, options?: { noFollowSymlinks?: boolean; }): Promise; /** * Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`. * Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as * tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every * revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant * to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement. * * TWO error classes, deliberately split: * - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT — * the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT * call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin * guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not * short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is * the rare degenerate case. This matches the ask's "refusal, not a throw" contract.) * - fs-layer errors (missing file, a directory), BA-4 param-guard violations, and a SOURCE file larger * than the `maxBytes` cap (read bounded via `readBounded`, never fully loaded to find out) THROW at * the tool boundary, before any edit is computed — no change made. * * BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg): * `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an * absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal * deletion; an absent `newText` is not. * * ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying * the original's mode → rename over the original. Any throw before the rename leaves the original * byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently * drop the executable bit. * * LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in * `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of * `newText` lands verbatim. * @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args * @param {{noFollowSymlinks?: boolean}} [options] * @returns {Promise} */ declare function editFile({ path: rawPath, oldText, newText, maxBytes }: { path: string; oldText: string; newText: string; maxBytes?: number; }, options?: { noFollowSymlinks?: boolean; }): Promise; /** * ONE shared open path for every file-open site in this module (`shell_read`, `shell_write`, * `shell_edit`, and `shell_grep`'s root check + per-file reads). With `noFollowSymlinks` off this * is a plain `fs.open` — the caller's `flags` decide read/write/create semantics, byte-identical to * this module's pre-existing behavior. With it on, refuses to follow a symlink at the FINAL path * component (opt-in — never covers a symlinked PARENT directory; see the top-of-file CAVEAT for * bareguard's resolved-path containment side of that, upcoming but not yet published). * * Two implementations when the flag is on, selected by whether the platform exposes `O_NOFOLLOW` * (present on linux/macOS, `undefined` on Windows): * - Native: pass `O_NOFOLLOW` straight to the kernel via `fs.open`. Atomic — there is no window * between checking and opening. A symlink at the final component (file, dir, or DANGLING) * makes the open fail with `ELOOP` before anything is created or read, which we translate to * `symlinkRefusalError`. Any other error (ENOENT, EACCES, …) passes through unchanged. * - Fallback (`O_NOFOLLOW` undefined): `fs.lstat` the path first and refuse if it is a symlink, * then `fs.open` normally. This is NOT atomic — a symlink planted between the `lstat` and the * `open` slips through (TOCTOU) — documented as a known Windows limitation, not a bug: Windows * has no `O_NOFOLLOW`-equivalent open flag reachable from Node. `lstat` throwing `ENOENT` means * "nothing there yet," which is fine for a write/create call — the open below still runs and * either creates the file or throws its own ENOENT for a read. * @param {string} toolName * @param {string} resolvedPath * @param {number} flags * @param {number} [mode] * @param {{noFollowSymlinks?: boolean, constants?: {O_NOFOLLOW?: number}}} [options] - `constants` is * an injectable override, used by tests to force the Windows fallback branch on a non-Windows CI * box without monkeypatching `fs.constants` globally. */ declare function openFile(toolName: string, resolvedPath: string, flags: number, mode?: number, options?: { noFollowSymlinks?: boolean; constants?: { O_NOFOLLOW?: number; }; }): Promise; /** * Narrows (does not close) the directory-listing race: after opening a directory by FD we still * have to list it by PATH — Node's `fs/promises` exposes no `fdopendir` — so a directory or symlink * swapped in at that path between the open and the `readdir` could make us list a DIFFERENT * directory's NAMES. This never leaks file CONTENTS (every content read in this module goes through * the already-open fd, never re-opened by path), only which names are reported for a listing. * * Re-`lstat`s the path right after the read and refuses (throws, `ELOOP`) unless its `dev`+`ino` * still match the handle that was opened — catching both a swap-to-a-symlink (whose own inode won't * match the real directory's) and a swap-to-a-different-real-directory (a different inode). * * RESIDUAL, documented not closed: a swap-and-swap-back that lands back on the original dev+ino * before this check runs is undetectable — any check that must re-consult the path shares this * limit. Only called when `noFollowSymlinks` is on; the recheck is inert otherwise. * @param {string} toolName * @param {string} resolvedPath * @param {import('node:fs').Stats} handleStat */ declare function assertDirStillMatchesHandle(toolName: string, resolvedPath: string, handleStat: import("node:fs").Stats): Promise; /** * Expand a leading `~`/`~/…` to the real home directory via `os.homedir()` — matching how bareguard's * own config-side `~` expansion works, so the SAME string means the same absolute path on both sides * of a gate check. Throws a clear error rather than ever silently degrading `~/x` to `/x`: * the previous `process.env.HOME || process.env.USERPROFILE || ''` fallback did exactly that when * `HOME` was unset or empty — a `~`-rooted path would resolve to the FILESYSTEM ROOT, not "no home," * an under-modeled-boundary bug (an ambiguous case rounding toward "works" instead of surfacing). * @param {string} p * @param {() => string} [homedirFn] - Injectable override, used ONLY by tests to force the * empty/throwing-homedir case deterministically, without monkeypatching `os.homedir` globally * (which would affect every other test in this process). Production call sites never pass this. * @returns {string} */ declare function expandHome(p: string, homedirFn?: () => string): string; import fs = require("node:fs/promises");