bare-agent 0.46.6 → 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/bareagent.context.md +9 -1
- package/examples/with-bareguard.mjs +12 -5
- package/package.json +2 -2
- package/primitives.json +13 -4
- 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/bareagent.context.md
CHANGED
|
@@ -19,7 +19,7 @@ Eight entry points:
|
|
|
19
19
|
- `require('bare-agent/providers')` — OpenAI, Anthropic, Ollama, CLIPipe, Fallback (the canonical short names; `*Provider` aliases — `OpenAIProvider`, `AnthropicProvider`, etc. — are also exported and match the class names, so either destructure works, v0.12.1+)
|
|
20
20
|
- `require('bare-agent/stores')` — SQLite (FTS5), JsonFile
|
|
21
21
|
- `require('bare-agent/transports')` — JsonlTransport
|
|
22
|
-
- `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, createDeferTool, spawnChild, readDeferQueue, liteCtxMcpBridgeConfig
|
|
22
|
+
- `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, resolveToolPath, createSpawnTool, createDeferTool, spawnChild, readDeferQueue, liteCtxMcpBridgeConfig
|
|
23
23
|
- `require('bare-agent/mcp')` — createMCPBridge (returns `tools` + `metaTools`), discoverServers, buildMetaTools
|
|
24
24
|
- `require('bare-agent/bareguard')` — wireGate (one-line bareguard Gate integration), defaultActionTranslator
|
|
25
25
|
|
|
@@ -526,6 +526,8 @@ const { policy, onToolResult } = wireGate(gate, {
|
|
|
526
526
|
|
|
527
527
|
`onLlmResult` always uses `{type:'llm'}` regardless of the translator (so budget rules match without translator collusion). `defaultActionTranslator` is exported for composition. **A tool is NOT auto-gated by the fs/bash primitives without this translator** — e.g. `shell_write` runs the write but `fs.writeScope` only enforces once `shell_write` → `{type:'write', path}`; the default `{type:'shell_write'}` matches `tools.allow/denylist` only. Verified live: with the translator, `gate.check` ALLOWs `shell_run ["ls","/tmp"]` and DENYs `shell_read /etc/passwd` (`[deny: fs.readScope]`), and an out-of-scope `shell_write` is denied **before** `execute` (nothing touches disk).
|
|
528
528
|
|
|
529
|
+
> **Canonicalize the path before the gate sees it.** bareguard's `fs` primitive checks the path you hand it WITHOUT canonicalizing it — a `~`-prefixed or relative `args.path` reads `fs.readScope`/`fs.writeScope` differently than the absolute path the tool actually opens. Call `resolveToolPath` (exported from `bare-agent/tools` alongside `createShellTools`) on `args.path` in the translator — either put the result in an explicit `path` field (as the snippet above's sibling example, `examples/with-bareguard.mjs`, does) or, if relying on bareguard reading `args.path` directly, replace `args.path` with the canonicalized value before returning. Same string in, same string opened.
|
|
530
|
+
|
|
529
531
|
**Bounding tool rounds — use `limits.maxToolRounds` (bareguard 0.4.2+), not doubled `maxTurns`.** `limits.maxTurns` ticks on every `gate.record` (LLM + tool), so an "N LLM-tool round" cap is `maxTurns: N*2`. `limits.maxToolRounds: N` ticks only on non-`llm` records and gives the natural semantic — pairs cleanly with our split `onLlmResult` / `onToolResult` (the LLM side writes `{type:'llm'}` records which the counter skips). Halt severity, same shape as `maxTurns`, rebuilt from audit on cold-start.
|
|
530
532
|
|
|
531
533
|
**`HaltError` reachable from the public API (v0.10.1+).** `require('bare-agent').HaltError`, `require('bare-agent/errors').HaltError`. Adopters whose policy shim throws `HaltError` get identity-equal class across module boundaries — Loop's `instanceof HaltError` catches it cleanly.
|
|
@@ -1414,6 +1416,12 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
|
|
|
1414
1416
|
|
|
1415
1417
|
**Zero baked-in allowlist.** The library ships the primitives; gating is bareguard's job via the standard `wireGate(gate)` wiring.
|
|
1416
1418
|
|
|
1419
|
+
> **Path canonicalization + symlink refusal, together.** bareguard's own resolved-path (symlink) containment check is planned for an upcoming release, not yet published; until then the gate matches paths lexically, so a symlinked parent dir is NOT contained even when the link sits inside an allowed scope. bare-agent canonicalizes the path (`~` + resolve, via `resolveToolPath`) before the gate check and opens that same path — this closes a *different* gap (the gate judging a `~`/relative path in a different form than the one the tool opens), not the lexical-match gap above. `noFollowSymlinks` refuses a symlinked final path *component* at open time. Together they narrow but do not close the check-then-open window, and neither covers hardlinks.
|
|
1420
|
+
>
|
|
1421
|
+
> **Opt-in symlink refusal:** `createShellTools({ noFollowSymlinks: true })` makes `shell_read`, `shell_write`, `shell_edit`, and `shell_grep` refuse to open a path whose **final** path component is a symlink — a file, a dir, or dangling — instead of silently following it. Refusal is a **thrown** error with `err.code === 'ELOOP'` (feeds the Loop's `maxIdenticalToolErrors` guard like any other tool error), and for `shell_write` the link's target is never created or touched. Scope is the final component **only** — a symlinked *parent* directory is not covered (per the canonicalization note above), and neither is the `cwd` option of `shell_run`/`shell_exec` — it only gets `~` expansion, never this flag or `resolveToolPath`'s full canonicalization, so a symlinked `cwd` is always followed. `shell_grep` refuses only a symlinked **root** path loudly, via the same atomic open every other tool uses (open-then-fstat, not a look-then-open `lstat`); a file that turns into a symlink partway through the walk is skipped silently, the same as any other read error. The check is atomic (`O_NOFOLLOW`) on linux/macOS; on Windows, which has no `O_NOFOLLOW`-equivalent open flag, it falls back to a non-atomic `lstat`-then-open check — a symlink planted in that gap slips through. A directory listing (`shell_read` on a dir, `shell_grep`'s root when it's a dir) additionally rechecks the path's `dev`+`ino` against the open handle right after listing — Node has no `fdopendir`, so the listing itself still goes by path, and this narrows (does not close) the window where a directory swapped in between the open and the listing would report a different directory's names; a swap-and-swap-back inside that narrow window still passes undetected. Default `false` — omitting the option is byte-identical to the pre-existing behavior.
|
|
1422
|
+
>
|
|
1423
|
+
> **`resolveToolPath(p)`** — exported alongside `createShellTools` from `bare-agent/tools`. THE canonicalizer every shell file tool applies internally before opening: expand `~`/`~/…` via `os.homedir()` (throws rather than silently degrading `~/x` to `/x` when no home directory can be determined), then `path.resolve` against `process.cwd()`. Idempotent; rejects a non-string/empty path (thrown, naming only the type/category of the bad value, never the value itself) — every file tool routes through it, so `shell_read`/`shell_grep` with `""` now error instead of silently reading/searching `process.cwd()`. Call it in your `actionTranslator` on `args.path` before returning the gated action, so the string the gate judges is the exact string the tool opens.
|
|
1424
|
+
|
|
1417
1425
|
> **⚠️ `shell_write` requires `content` — and a gate cannot cover for it (v0.27+).** `content` used to default to `''`, so a tool call that OMITTED it silently overwrote the target with **zero bytes** and returned `"wrote 0 bytes to <path>"` as success. That is the ordinary shape of a model hitting its **output-token cap** mid-generation on a long file — observed live emptying a 1789-line source file. **No policy can catch it:** a 0-byte write is a *legal* write, and bareguard's `fs` primitive judges `{type:'write', path}` without inspecting the body (the gate correctly `allow`s it). `shell_write` now **rejects** an absent, `null`, or non-string `content` and leaves the file byte-identical; the error tells the model to retry with the full content. An explicit `content: ""` still empties the file — that one is deliberate.
|
|
1418
1426
|
|
|
1419
1427
|
> **`shell_edit` — the surgical write (BA-13).** Changing one line of an 800-line file with `shell_write` forces the model to re-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 lines it never meant to touch — the BA-4/BA-6 class). `shell_edit({path, oldText, newText})` emits only the anchor + replacement. Semantics worth knowing: `oldText` must match **exactly once** (a 0/2+ match is a refusal *returned as the tool result*, so the loop continues and the model widens the anchor — not a throw, so a repeated-identical miss is bounded by maxTurns/budget, not the spin guard); missing/empty `oldText` or missing/non-string `newText` **throw** (BA-4 guards; `newText:""` is a legal deletion); the write is **atomic** (sibling temp + rename, mode preserved) so an fs failure never leaves a partial file; and `newText` is a **literal splice** (a `$&`/`$1` in it lands verbatim, unlike `String.replace`). Gate it exactly like `shell_write` but as `{type:'edit'}` — **bareguard gates `edit` by the same `fs.writeScope` as `write` with zero config** (its FS primitive's `FS_TYPES` already includes `edit`).
|
|
@@ -21,7 +21,7 @@ import { createRequire } from 'node:module';
|
|
|
21
21
|
const require = createRequire(import.meta.url);
|
|
22
22
|
const { Loop, wireGate } = require('bare-agent');
|
|
23
23
|
const { OpenAI } = require('bare-agent/providers');
|
|
24
|
-
const { createShellTools } = require('bare-agent/tools');
|
|
24
|
+
const { createShellTools, resolveToolPath } = require('bare-agent/tools');
|
|
25
25
|
|
|
26
26
|
// 1. Build the gate. Every primitive is optional with sensible defaults.
|
|
27
27
|
const gate = new Gate({
|
|
@@ -50,18 +50,25 @@ await gate.init();
|
|
|
50
50
|
// primitives: those fire only on `action.type ∈ {bash, read, write, edit}` and read `action.cmd`/`action.path`.
|
|
51
51
|
// So to make the `bash.allow` + `fs.readScope` config above actually enforce, we MUST translate the shell
|
|
52
52
|
// tools into those primitive shapes — otherwise the caps are silently dead (relayfact F7/BA-3).
|
|
53
|
+
//
|
|
54
|
+
// `path` below is `resolveToolPath(args.path)`, not the raw arg: bareguard's fs primitive checks the
|
|
55
|
+
// path you hand it WITHOUT canonicalizing it, so a `~`-prefixed or relative path would be judged
|
|
56
|
+
// against `fs.readScope`/`fs.writeScope` in a different form than the one the tool actually opens —
|
|
57
|
+
// gate string and opened string must be the SAME string. `resolveToolPath` is the one canonicalizer
|
|
58
|
+
// (expand `~` via os.homedir(), then path.resolve) every shell file tool already applies internally
|
|
59
|
+
// before opening; calling it here too means the gate sees exactly what gets opened.
|
|
53
60
|
const actionTranslator = (toolName, args, ctx) => {
|
|
54
61
|
switch (toolName) {
|
|
55
62
|
// shell_run is argv (no shell); bareguard's bash.allow matches `cmd.startsWith(prefix)`, so join argv[0..].
|
|
56
63
|
case 'shell_run': return { type: 'bash', cmd: (args?.argv || []).join(' '), args, _ctx: ctx ?? null };
|
|
57
64
|
case 'shell_exec': return { type: 'bash', cmd: args?.command, args, _ctx: ctx ?? null };
|
|
58
|
-
// shell_read / shell_grep are reads — gate them through fs.readScope.
|
|
65
|
+
// shell_read / shell_grep are reads — gate them through fs.readScope, on the canonicalized path.
|
|
59
66
|
case 'shell_read':
|
|
60
|
-
case 'shell_grep': return { type: 'read', path: args?.path, args, _ctx: ctx ?? null };
|
|
67
|
+
case 'shell_grep': return { type: 'read', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
|
|
61
68
|
// shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
|
|
62
|
-
case 'shell_write': return { type: 'write', path: args?.path, args, _ctx: ctx ?? null };
|
|
69
|
+
case 'shell_write': return { type: 'write', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
|
|
63
70
|
// shell_edit is an anchored edit — bareguard gates {type:'edit'} by the SAME fs.writeScope as write.
|
|
64
|
-
case 'shell_edit': return { type: 'edit', path: args?.path, args, _ctx: ctx ?? null };
|
|
71
|
+
case 'shell_edit': return { type: 'edit', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
|
|
65
72
|
default: return { type: toolName, args, _ctx: ctx ?? null };
|
|
66
73
|
}
|
|
67
74
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"index.d.ts",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
"scripts": {
|
|
98
|
-
"test": "node --test test/**/*.test.js",
|
|
98
|
+
"test": "node --test test/**/*.test.js test/**/*.test.mjs",
|
|
99
99
|
"test:unit": "node --test \"test/!(integration*|*mcp*|spawn*).test.js\"",
|
|
100
100
|
"typecheck": "tsc --noEmit",
|
|
101
101
|
"prebuild:types": "node scripts/clean-types.js",
|
package/primitives.json
CHANGED
|
@@ -141,9 +141,9 @@
|
|
|
141
141
|
"category": "tools",
|
|
142
142
|
"when": "you want to give an agent shell/file tools (read, grep, write, edit, run, exec) — cross-platform, pure Node, zero deps",
|
|
143
143
|
"import": "import { createShellTools } from 'bare-agent/tools'",
|
|
144
|
-
"signature": "createShellTools() => {tools: ToolDef[]}",
|
|
145
|
-
"fails": "never throws at creation; gating is the caller's via Loop({ policy }) and fs.writeScope,
|
|
146
|
-
"example": "const { tools } = createShellTools();\nconst loop = new Loop({ provider, tools, policy });"
|
|
144
|
+
"signature": "createShellTools(options?: {noFollowSymlinks?: boolean}) => {tools: ToolDef[]}",
|
|
145
|
+
"fails": "never throws at creation; gating is the caller's via Loop({ policy }) and fs.writeScope, shell_edit refuses a non-unique anchor as a tool result (file untouched), and with noFollowSymlinks:true the four file tools throw ELOOP on a symlinked final path component.",
|
|
146
|
+
"example": "const { tools } = createShellTools();\nconst loop = new Loop({ provider, tools, policy });\nconst { tools: safeTools } = createShellTools({ noFollowSymlinks: true });"
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
"name": "createSpawnTool",
|
|
@@ -361,10 +361,19 @@
|
|
|
361
361
|
"fails": "skips empty spans and never fabricates; a provider HaltError propagates clean. Each pass forwards usage via onLlmResult; a fact counts once via ctx.recordMemoryOp.",
|
|
362
362
|
"example": "const { facts } = await remember(spans, { provider, store });\nconsole.log(`consolidated ${facts.length} durable facts`);"
|
|
363
363
|
},
|
|
364
|
+
{
|
|
365
|
+
"name": "resolveToolPath",
|
|
366
|
+
"category": "tools",
|
|
367
|
+
"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",
|
|
368
|
+
"import": "import { resolveToolPath } from 'bare-agent/tools'",
|
|
369
|
+
"signature": "resolveToolPath(p: string) => string",
|
|
370
|
+
"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",
|
|
371
|
+
"example": "const resolved = resolveToolPath('~/notes.txt');"
|
|
372
|
+
},
|
|
364
373
|
{
|
|
365
374
|
"name": "Retry",
|
|
366
375
|
"category": "resilience",
|
|
367
|
-
"when": "you want backoff-with-jitter around a flaky async call (a provider request, a plan step) — the retry seam Loop and runPlan wrap providers
|
|
376
|
+
"when": "you want backoff-with-jitter around a flaky async call (a provider request, a plan step) — the retry seam that Loop and runPlan wrap around providers",
|
|
368
377
|
"import": "import { Retry } from 'bare-agent'",
|
|
369
378
|
"signature": "new Retry(options?: RetryOptions)",
|
|
370
379
|
"fails": "rethrows the last error once attempts are exhausted; by default only transient errors (429/5xx/ECONNRESET/ETIMEDOUT) are retried.",
|
package/src/retry.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type RetryOptions = {
|
|
|
23
23
|
export class Retry {
|
|
24
24
|
/**
|
|
25
25
|
* @param {RetryOptions} [options={}]
|
|
26
|
-
* @when you want backoff-with-jitter around a flaky async call (a provider request, a plan step) — the retry seam Loop and runPlan wrap providers
|
|
26
|
+
* @when you want backoff-with-jitter around a flaky async call (a provider request, a plan step) — the retry seam that Loop and runPlan wrap around providers
|
|
27
27
|
* @fails rethrows the last error once attempts are exhausted; by default only transient errors (429/5xx/ECONNRESET/ETIMEDOUT) are retried.
|
|
28
28
|
* @example
|
|
29
29
|
* const retry = new Retry({ maxAttempts: 3, jitter: true });
|
package/src/retry.js
CHANGED
|
@@ -25,7 +25,7 @@ const DEFAULT_RETRY_ON = (err) => {
|
|
|
25
25
|
class Retry {
|
|
26
26
|
/**
|
|
27
27
|
* @param {RetryOptions} [options={}]
|
|
28
|
-
* @when you want backoff-with-jitter around a flaky async call (a provider request, a plan step) — the retry seam Loop and runPlan wrap providers
|
|
28
|
+
* @when you want backoff-with-jitter around a flaky async call (a provider request, a plan step) — the retry seam that Loop and runPlan wrap around providers
|
|
29
29
|
* @fails rethrows the last error once attempts are exhausted; by default only transient errors (429/5xx/ECONNRESET/ETIMEDOUT) are retried.
|
|
30
30
|
* @example
|
|
31
31
|
* const retry = new Retry({ maxAttempts: 3, jitter: true });
|
package/src/tools.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createBrowsingTools } from "../tools/browse";
|
|
2
2
|
import { createMobileTools } from "../tools/mobile";
|
|
3
3
|
import { createShellTools } from "../tools/shell";
|
|
4
|
+
import { resolveToolPath } from "../tools/shell";
|
|
4
5
|
import { createSpawnTool } from "../tools/spawn";
|
|
5
6
|
import { spawnChild } from "../tools/spawn";
|
|
6
7
|
import { createDeferTool } from "../tools/defer";
|
|
7
8
|
import { readQueue as readDeferQueue } from "../tools/defer";
|
|
8
9
|
import { liteCtxMcpBridgeConfig } from "../tools/litectx-mcp";
|
|
9
|
-
export { createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, spawnChild, createDeferTool, readDeferQueue, liteCtxMcpBridgeConfig };
|
|
10
|
+
export { createBrowsingTools, createMobileTools, createShellTools, resolveToolPath, createSpawnTool, spawnChild, createDeferTool, readDeferQueue, liteCtxMcpBridgeConfig };
|
package/src/tools.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { createBrowsingTools } = require('../tools/browse');
|
|
4
4
|
const { createMobileTools } = require('../tools/mobile');
|
|
5
|
-
const { createShellTools } = require('../tools/shell');
|
|
5
|
+
const { createShellTools, resolveToolPath } = require('../tools/shell');
|
|
6
6
|
const { createSpawnTool, spawnChild } = require('../tools/spawn');
|
|
7
7
|
const { createDeferTool, readQueue: readDeferQueue } = require('../tools/defer');
|
|
8
8
|
const { liteCtxMcpBridgeConfig } = require('../tools/litectx-mcp');
|
|
@@ -11,6 +11,7 @@ module.exports = {
|
|
|
11
11
|
createBrowsingTools,
|
|
12
12
|
createMobileTools,
|
|
13
13
|
createShellTools,
|
|
14
|
+
resolveToolPath,
|
|
14
15
|
createSpawnTool,
|
|
15
16
|
spawnChild,
|
|
16
17
|
createDeferTool,
|
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
|
|
41
|
-
*
|
|
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,
|
|
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)
|
|
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");
|
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
|
+
};
|