bare-agent 0.46.6 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bareagent.context.md +23 -4
- package/examples/with-bareguard.mjs +23 -22
- package/package.json +4 -4
- package/primitives.json +13 -4
- package/src/bareguard-adapter.d.ts +33 -8
- package/src/bareguard-adapter.js +98 -15
- package/src/loop.d.ts +6 -2
- package/src/loop.js +2 -2
- package/src/provider-ollama.d.ts +12 -0
- package/src/provider-ollama.js +7 -1
- package/src/provider-openai.d.ts +14 -0
- package/src/provider-openai.js +8 -1
- package/src/provider-toolcalls.d.ts +15 -3
- package/src/provider-toolcalls.js +28 -8
- package/src/retry.d.ts +1 -1
- package/src/retry.js +1 -1
- package/src/tools.d.ts +2 -1
- package/src/tools.js +2 -1
- package/tools/shell.d.ts +136 -6
- package/tools/shell.js +406 -59
- package/types/index.d.ts +6 -1
package/src/provider-openai.js
CHANGED
|
@@ -72,6 +72,11 @@ function isLoopbackHost(hostname) {
|
|
|
72
72
|
* OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
|
|
73
73
|
* Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
|
|
74
74
|
* self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
|
|
75
|
+
* @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's `function.arguments`
|
|
76
|
+
* JSON fails to parse, also attach the raw string (verbatim, capped at 500 chars, `rawTruncated:true`
|
|
77
|
+
* when longer) to `malformedToolCall.rawArguments`. Off by default, independent of `exposeErrorBody` —
|
|
78
|
+
* the raw blob is model-generated, not an upstream error body, but the same "don't leak an unbounded
|
|
79
|
+
* unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
|
|
75
80
|
*/
|
|
76
81
|
|
|
77
82
|
class OpenAIProvider {
|
|
@@ -93,6 +98,8 @@ class OpenAIProvider {
|
|
|
93
98
|
this.deadlineMs = options.deadlineMs;
|
|
94
99
|
// BA-24 (fwdloop): use the legacy `max_tokens` key. Default false ⇒ `max_completion_tokens` (GPT-5-safe).
|
|
95
100
|
this.legacyMaxTokens = options.legacyMaxTokens === true;
|
|
101
|
+
// BA-27 follow-up: expose the raw malformed tool-call arguments string. Off by default.
|
|
102
|
+
this.exposeMalformedArgs = options.exposeMalformedArgs === true;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
/**
|
|
@@ -161,7 +168,7 @@ class OpenAIProvider {
|
|
|
161
168
|
id: tc.id,
|
|
162
169
|
name: tc.function.name,
|
|
163
170
|
arguments: JSON.parse(tc.function.arguments),
|
|
164
|
-
}));
|
|
171
|
+
}), { exposeMalformedArgs: this.exposeMalformedArgs });
|
|
165
172
|
|
|
166
173
|
return {
|
|
167
174
|
text: msg.content || '',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export type ToolCall = import("../types").ToolCall;
|
|
2
|
-
/** @typedef {import('../types').ToolCall} ToolCall */
|
|
3
2
|
/**
|
|
4
3
|
* BA-27 — parse a round's raw tool calls into the neutral {@link ToolCall} shape WITHOUT throwing on
|
|
5
4
|
* malformed arguments.
|
|
@@ -16,14 +15,27 @@ export type ToolCall = import("../types").ToolCall;
|
|
|
16
15
|
* All-or-nothing (mirrors BA-4's refusal to execute a truncated round's calls): a partial set risks
|
|
17
16
|
* running half a decomposed intent, so one bad call voids the whole round's calls.
|
|
18
17
|
*
|
|
18
|
+
* `opts.exposeMalformedArgs` (default off, mirrors `exposeErrorBody`): when on, the marker also
|
|
19
|
+
* carries `rawArguments` — the raw `function.arguments` VERBATIM, capped at 500 chars (`rawTruncated:
|
|
20
|
+
* true` added only when it was longer). Non-string raw arguments (e.g. Ollama's well-formed object
|
|
21
|
+
* case reaching this catch some other way) are never exposed — only a string is a model-generated
|
|
22
|
+
* blob worth surfacing for debugging; an object was already structurally valid before whatever else
|
|
23
|
+
* threw. Off by default for the same reason as `exposeErrorBody`: an arbitrary model-generated string
|
|
24
|
+
* landing in a log/audit row unbounded is its own leak surface.
|
|
25
|
+
*
|
|
19
26
|
* @param {any[]} rawToolCalls - provider-native tool-call entries (may be undefined/empty)
|
|
20
27
|
* @param {(tc: any) => ToolCall} mapOne - maps one raw entry to a ToolCall; MAY throw on bad arguments
|
|
21
|
-
* @
|
|
28
|
+
* @param {{ exposeMalformedArgs?: boolean }} [opts]
|
|
29
|
+
* @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true } }}
|
|
22
30
|
*/
|
|
23
|
-
export function parseToolCalls(rawToolCalls: any[], mapOne: (tc: any) => ToolCall
|
|
31
|
+
export function parseToolCalls(rawToolCalls: any[], mapOne: (tc: any) => ToolCall, opts?: {
|
|
32
|
+
exposeMalformedArgs?: boolean;
|
|
33
|
+
}): {
|
|
24
34
|
toolCalls: ToolCall[];
|
|
25
35
|
malformedToolCall?: {
|
|
26
36
|
name: string | undefined;
|
|
27
37
|
error: string;
|
|
38
|
+
rawArguments?: string;
|
|
39
|
+
rawTruncated?: true;
|
|
28
40
|
};
|
|
29
41
|
};
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
/** @typedef {import('../types').ToolCall} ToolCall */
|
|
4
4
|
|
|
5
|
+
// Raw-arguments exposure cap. Matches the sibling opt-in error-body exposure pattern
|
|
6
|
+
// (`exposeErrorBody`) elsewhere in the providers: off by default, and even when on, bounded —
|
|
7
|
+
// an arbitrarily long model-generated string reaching a log/receipt unbounded is its own risk.
|
|
8
|
+
const RAW_ARGUMENTS_MAX_CHARS = 500;
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* BA-27 — parse a round's raw tool calls into the neutral {@link ToolCall} shape WITHOUT throwing on
|
|
7
12
|
* malformed arguments.
|
|
@@ -18,11 +23,20 @@
|
|
|
18
23
|
* All-or-nothing (mirrors BA-4's refusal to execute a truncated round's calls): a partial set risks
|
|
19
24
|
* running half a decomposed intent, so one bad call voids the whole round's calls.
|
|
20
25
|
*
|
|
26
|
+
* `opts.exposeMalformedArgs` (default off, mirrors `exposeErrorBody`): when on, the marker also
|
|
27
|
+
* carries `rawArguments` — the raw `function.arguments` VERBATIM, capped at 500 chars (`rawTruncated:
|
|
28
|
+
* true` added only when it was longer). Non-string raw arguments (e.g. Ollama's well-formed object
|
|
29
|
+
* case reaching this catch some other way) are never exposed — only a string is a model-generated
|
|
30
|
+
* blob worth surfacing for debugging; an object was already structurally valid before whatever else
|
|
31
|
+
* threw. Off by default for the same reason as `exposeErrorBody`: an arbitrary model-generated string
|
|
32
|
+
* landing in a log/audit row unbounded is its own leak surface.
|
|
33
|
+
*
|
|
21
34
|
* @param {any[]} rawToolCalls - provider-native tool-call entries (may be undefined/empty)
|
|
22
35
|
* @param {(tc: any) => ToolCall} mapOne - maps one raw entry to a ToolCall; MAY throw on bad arguments
|
|
23
|
-
* @
|
|
36
|
+
* @param {{ exposeMalformedArgs?: boolean }} [opts]
|
|
37
|
+
* @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true } }}
|
|
24
38
|
*/
|
|
25
|
-
function parseToolCalls(rawToolCalls, mapOne) {
|
|
39
|
+
function parseToolCalls(rawToolCalls, mapOne, opts = {}) {
|
|
26
40
|
const raw = rawToolCalls || [];
|
|
27
41
|
/** @type {ToolCall[]} */
|
|
28
42
|
const toolCalls = [];
|
|
@@ -30,13 +44,19 @@ function parseToolCalls(rawToolCalls, mapOne) {
|
|
|
30
44
|
try {
|
|
31
45
|
toolCalls.push(mapOne(tc));
|
|
32
46
|
} catch (e) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
error: e instanceof Error ? e.message : String(e),
|
|
38
|
-
},
|
|
47
|
+
/** @type {{ name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true }} */
|
|
48
|
+
const malformedToolCall = {
|
|
49
|
+
name: tc && tc.function ? tc.function.name : undefined,
|
|
50
|
+
error: e instanceof Error ? e.message : String(e),
|
|
39
51
|
};
|
|
52
|
+
if (opts.exposeMalformedArgs) {
|
|
53
|
+
const rawArgs = tc && tc.function ? tc.function.arguments : undefined;
|
|
54
|
+
if (typeof rawArgs === 'string') {
|
|
55
|
+
malformedToolCall.rawArguments = rawArgs.slice(0, RAW_ARGUMENTS_MAX_CHARS);
|
|
56
|
+
if (rawArgs.length > RAW_ARGUMENTS_MAX_CHARS) malformedToolCall.rawTruncated = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { toolCalls: [], malformedToolCall };
|
|
40
60
|
}
|
|
41
61
|
}
|
|
42
62
|
return { toolCalls };
|
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");
|