bare-agent 0.28.0 → 0.29.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/README.md +1 -0
- package/bareagent.context.md +7 -2
- package/examples/with-bareguard.mjs +2 -0
- package/package.json +1 -1
- package/tools/shell.d.ts +39 -0
- package/tools/shell.js +129 -6
package/README.md
CHANGED
|
@@ -161,6 +161,7 @@ const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate, {
|
|
|
161
161
|
if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx };
|
|
162
162
|
if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx };
|
|
163
163
|
if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope
|
|
164
|
+
if (toolName === 'shell_edit') return { type: 'edit', args, _ctx: ctx }; // same fs.writeScope as write
|
|
164
165
|
return defaultActionTranslator(toolName, args, ctx);
|
|
165
166
|
},
|
|
166
167
|
});
|
package/bareagent.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bareagent — Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring bareagent into a project.
|
|
4
|
-
> v0.
|
|
4
|
+
> v0.29.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
|
|
5
5
|
>
|
|
6
6
|
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
|
|
7
7
|
|
|
@@ -65,7 +65,8 @@ Eight entry points:
|
|
|
65
65
|
| Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
|
|
66
66
|
| Control Android/iOS devices | createMobileTools + Loop |
|
|
67
67
|
| Control mobile (token-efficient, disk-based) | `baremobile` CLI session — snapshots to `.baremobile/*.yml` |
|
|
68
|
-
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/run/exec) + Loop({ policy }) — gate `shell_write` via `fs.writeScope` with an actionTranslator |
|
|
68
|
+
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/**edit**/run/exec) + Loop({ policy }) — gate `shell_write`/`shell_edit` via `fs.writeScope` with an actionTranslator |
|
|
69
|
+
| Change one span of a big file without re-emitting the whole thing | `shell_edit({path, oldText, newText})` — anchored exact-once replace; the surgical alternative to whole-file `shell_write` (BA-13). Gate it as `{type:'edit'}` |
|
|
69
70
|
| Auto-discover MCP servers from IDE configs | createMCPBridge |
|
|
70
71
|
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
|
|
71
72
|
| Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
|
|
@@ -423,6 +424,7 @@ const { policy, onToolResult } = wireGate(gate, {
|
|
|
423
424
|
if (toolName === 'shell_run') return { type: 'bash', args, _ctx: ctx }; // reads args.argv → joins to cmd
|
|
424
425
|
if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx }; // reads args.path
|
|
425
426
|
if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope (reads args.path)
|
|
427
|
+
if (toolName === 'shell_edit') return { type: 'edit', args, _ctx: ctx }; // same fs.writeScope as write (reads args.path)
|
|
426
428
|
return { type: toolName, args, _ctx: ctx }; // fall through to defaultActionTranslator
|
|
427
429
|
},
|
|
428
430
|
});
|
|
@@ -1247,6 +1249,7 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
|
|
|
1247
1249
|
|---|---|
|
|
1248
1250
|
| `shell_read` | Read a file (utf8, 256KB cap) or list a directory (tab-separated). `~` expands to home. |
|
|
1249
1251
|
| `shell_write` | Write (or `append:true`) UTF-8 text to a file, creating parent dirs. 5MB cap. No shell, so it gates cleanly through `fs.writeScope` once translated to `{type:'write'}`. **`content` is REQUIRED** — see the truncation guard below. |
|
|
1252
|
+
| `shell_edit` | Anchored exact-string replace — the **surgical** alternative to whole-file `shell_write` (BA-13). `{path, oldText, newText}`: `oldText` must occur **exactly once** (quote surrounding lines to be unique); `newText` is spliced in **verbatim** (`""` deletes). Returns a compact `edited <path>: 1 replacement` receipt, never the body. 0 or 2+ matches → a refusal **returned as the tool result** (the model re-anchors; file untouched). Gates through `fs.writeScope` translated to `{type:'edit'}` — see the note below. |
|
|
1250
1253
|
| `shell_grep` | JavaScript regex search across files. Walks directories, skips binary files, returns `{hits: [{file, line, text}], truncated, fileCount}`. |
|
|
1251
1254
|
| `shell_run` | Run a command with an **argv array** via `child_process.execFile` (no shell, no metacharacter interpretation). Returns `{stdout, stderr, code, timedOut}`. **Use this when you need a policy allowlist.** |
|
|
1252
1255
|
| `shell_exec` | Run a raw shell command string via `/bin/sh -c` (or `cmd.exe`). Returns the same shape. **Shell metacharacters are interpreted — naive allowlists are bypassable.** Use only when you genuinely need shell features (pipes, redirects, globs). |
|
|
@@ -1255,6 +1258,8 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
|
|
|
1255
1258
|
|
|
1256
1259
|
> **⚠️ `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.
|
|
1257
1260
|
|
|
1261
|
+
> **`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`).
|
|
1262
|
+
|
|
1258
1263
|
> **⚠️ `shell_exec` injection caveat.** `"ls"` passes a base-command allowlist like `args.command.split(/\s+/)[0]`, but so does `"ls;rm -rf /tmp/x"` — the shell runs both. **A base-command allowlist is NOT safe for `shell_exec`.** For policy-gated use, prefer `shell_run({argv})` and allow-list on `args.argv[0]` — there is no shell in that path, so metacharacters are just literal argument bytes. Use `shell_exec` only when the agent needs pipes/redirects/globs, and gate it at a higher level (human approval, narrow intent).
|
|
1259
1264
|
|
|
1260
1265
|
```javascript
|
|
@@ -60,6 +60,8 @@ const actionTranslator = (toolName, args, ctx) => {
|
|
|
60
60
|
case 'shell_grep': return { type: 'read', path: args?.path, args, _ctx: ctx ?? null };
|
|
61
61
|
// shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
|
|
62
62
|
case 'shell_write': return { type: 'write', path: args?.path, args, _ctx: ctx ?? null };
|
|
63
|
+
// 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 };
|
|
63
65
|
default: return { type: toolName, args, _ctx: ctx ?? null };
|
|
64
66
|
}
|
|
65
67
|
};
|
package/package.json
CHANGED
package/tools/shell.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ declare namespace _exports {
|
|
|
5
5
|
export { createShellTools };
|
|
6
6
|
export { _grepCore };
|
|
7
7
|
export { writeFile as _writeFile };
|
|
8
|
+
export { editFile as _editFile };
|
|
8
9
|
}
|
|
9
10
|
export = _exports;
|
|
10
11
|
type GrepArgs = {
|
|
@@ -97,3 +98,41 @@ declare function writeFile({ path: rawPath, content, append, maxBytes }: {
|
|
|
97
98
|
append?: boolean;
|
|
98
99
|
maxBytes?: number;
|
|
99
100
|
}): Promise<string>;
|
|
101
|
+
/**
|
|
102
|
+
* Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
|
|
103
|
+
* Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as
|
|
104
|
+
* tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every
|
|
105
|
+
* revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant
|
|
106
|
+
* to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement.
|
|
107
|
+
*
|
|
108
|
+
* TWO error classes, deliberately split:
|
|
109
|
+
* - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT —
|
|
110
|
+
* the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT
|
|
111
|
+
* call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin
|
|
112
|
+
* guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
|
|
113
|
+
* short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
|
|
114
|
+
* the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
|
|
115
|
+
* - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
|
|
116
|
+
*
|
|
117
|
+
* BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
|
|
118
|
+
* `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
|
|
119
|
+
* absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal
|
|
120
|
+
* deletion; an absent `newText` is not.
|
|
121
|
+
*
|
|
122
|
+
* ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying
|
|
123
|
+
* the original's mode → rename over the original. Any throw before the rename leaves the original
|
|
124
|
+
* byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently
|
|
125
|
+
* drop the executable bit.
|
|
126
|
+
*
|
|
127
|
+
* LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in
|
|
128
|
+
* `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
|
|
129
|
+
* `newText` lands verbatim.
|
|
130
|
+
* @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
|
|
131
|
+
* @returns {Promise<string>}
|
|
132
|
+
*/
|
|
133
|
+
declare function editFile({ path: rawPath, oldText, newText, maxBytes }: {
|
|
134
|
+
path: string;
|
|
135
|
+
oldText: string;
|
|
136
|
+
newText: string;
|
|
137
|
+
maxBytes?: number;
|
|
138
|
+
}): Promise<string>;
|
package/tools/shell.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* shell_read — read a file or list a directory
|
|
8
8
|
* shell_grep — regex search across files (JS regex, no grep/rg/findstr)
|
|
9
9
|
* shell_write — write/overwrite (or append to) a file, creating parent dirs (no shell)
|
|
10
|
+
* shell_edit — anchored exact-string replace: change one span without rewriting the whole file
|
|
10
11
|
* shell_run — run a command via an argv array (no shell, allowlist-friendly on argv[0])
|
|
11
12
|
* shell_exec — run a raw shell command with timeout + max buffer
|
|
12
13
|
*
|
|
@@ -16,11 +17,13 @@
|
|
|
16
17
|
* GATING WITH bareguard's fs/bash PRIMITIVES: these tools carry tool-named actions by default
|
|
17
18
|
* (`{ type:'shell_write' }`), which match `tools.allowlist`/`tools.denylist` but do NOT activate the
|
|
18
19
|
* `fs`/`bash` primitives — those need `action.type ∈ {read,write,edit,bash}` with `action.path`/`action.cmd`.
|
|
19
|
-
* To gate `shell_write` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it
|
|
20
|
-
* disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
|
|
21
|
-
* mapping (`shell_write` → `{ type:'write', path }`, `
|
|
22
|
-
* `shell_run`/`shell_exec` → `{ type:'bash', cmd }`).
|
|
23
|
-
*
|
|
20
|
+
* To gate `shell_write`/`shell_edit` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it
|
|
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 }`, `shell_read`/`shell_grep`
|
|
23
|
+
* → `{ type:'read', path }`, `shell_run`/`shell_exec` → `{ type:'bash', cmd }`). bareguard gates `edit` by
|
|
24
|
+
* `fs.writeScope` identically to `write` (its FS primitive's `FS_TYPES` includes `edit`), so a consumer that
|
|
25
|
+
* fences `write` gets `edit` fenced by the same scope with ZERO extra config. A write/edit tool alone is NOT
|
|
26
|
+
* auto-gated — validated by poc/ba2-write-tool-gate.mjs (without the translator the out-of-scope write leaks).
|
|
24
27
|
*
|
|
25
28
|
* CAVEAT (applies to read AND write scopes): bareguard's `fs` primitive matches paths LEXICALLY (no
|
|
26
29
|
* `realpath`/symlink resolution), so a symlink that lives INSIDE the allowed scope but points OUTSIDE it is
|
|
@@ -33,6 +36,7 @@
|
|
|
33
36
|
|
|
34
37
|
const fs = require('node:fs/promises');
|
|
35
38
|
const path = require('node:path');
|
|
39
|
+
const crypto = require('node:crypto');
|
|
36
40
|
const { exec, execFile } = require('node:child_process');
|
|
37
41
|
const { Worker } = require('node:worker_threads');
|
|
38
42
|
|
|
@@ -127,6 +131,101 @@ async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
|
|
|
127
131
|
return `${append ? 'appended' : 'wrote'} ${bytes} bytes to ${resolved}`;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
|
|
136
|
+
* Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as
|
|
137
|
+
* tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every
|
|
138
|
+
* revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant
|
|
139
|
+
* to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement.
|
|
140
|
+
*
|
|
141
|
+
* TWO error classes, deliberately split:
|
|
142
|
+
* - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT —
|
|
143
|
+
* the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT
|
|
144
|
+
* call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin
|
|
145
|
+
* guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
|
|
146
|
+
* short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
|
|
147
|
+
* the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
|
|
148
|
+
* - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
|
|
149
|
+
*
|
|
150
|
+
* BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
|
|
151
|
+
* `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
|
|
152
|
+
* absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal
|
|
153
|
+
* deletion; an absent `newText` is not.
|
|
154
|
+
*
|
|
155
|
+
* ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying
|
|
156
|
+
* the original's mode → rename over the original. Any throw before the rename leaves the original
|
|
157
|
+
* byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently
|
|
158
|
+
* drop the executable bit.
|
|
159
|
+
*
|
|
160
|
+
* LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in
|
|
161
|
+
* `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
|
|
162
|
+
* `newText` lands verbatim.
|
|
163
|
+
* @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
|
|
164
|
+
* @returns {Promise<string>}
|
|
165
|
+
*/
|
|
166
|
+
async function editFile({ path: rawPath, oldText, newText, maxBytes }) {
|
|
167
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0) {
|
|
168
|
+
throw new Error('shell_edit requires a non-empty "path" string');
|
|
169
|
+
}
|
|
170
|
+
if (typeof oldText !== 'string' || oldText.length === 0) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'shell_edit requires a non-empty "oldText" string to anchor the edit — refusing to edit, the file is unchanged. '
|
|
173
|
+
+ `Got ${oldText === undefined ? 'no oldText argument' : oldText === '' ? 'an empty string' : `oldText of type ${oldText === null ? 'null' : typeof oldText}`}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (typeof newText !== 'string') {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'shell_edit requires a "newText" string (pass newText:"" to delete the anchored text) — refusing to edit, the file is unchanged. '
|
|
179
|
+
+ `Got ${newText === undefined ? 'no newText argument' : `newText of type ${newText === null ? 'null' : typeof newText}`}`
|
|
180
|
+
+ '. If your output was cut short, retry with the full newText.',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const resolved = path.resolve(expandHome(rawPath));
|
|
185
|
+
// fs-layer errors (ENOENT for a missing file, EISDIR for a directory) throw — same surface as shell_read.
|
|
186
|
+
const content = await fs.readFile(resolved, 'utf8');
|
|
187
|
+
|
|
188
|
+
// Literal, non-overlapping occurrence count (split on a string does no regex interpretation).
|
|
189
|
+
const occurrences = content.split(oldText).length - 1;
|
|
190
|
+
if (occurrences === 0) {
|
|
191
|
+
return `shell_edit: oldText not found in ${resolved} — no change made. Quote the exact text to replace `
|
|
192
|
+
+ `(check whitespace and indentation), or read the file to re-anchor.`;
|
|
193
|
+
}
|
|
194
|
+
if (occurrences > 1) {
|
|
195
|
+
return `shell_edit: oldText occurs ${occurrences}× in ${resolved} — the anchor must match exactly once. `
|
|
196
|
+
+ `Widen it with surrounding lines so it is unique. No change made.`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const idx = content.indexOf(oldText);
|
|
200
|
+
const patched = content.slice(0, idx) + newText + content.slice(idx + oldText.length);
|
|
201
|
+
|
|
202
|
+
const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
|
|
203
|
+
const bytes = Buffer.byteLength(patched, 'utf8');
|
|
204
|
+
if (bytes > cap) {
|
|
205
|
+
throw new Error(`shell_edit result is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Atomic replace: a sibling temp (same dir → same filesystem → rename is atomic) with the original's mode.
|
|
209
|
+
const stat = await fs.stat(resolved);
|
|
210
|
+
const tmp = `${resolved}.shell_edit-${crypto.randomBytes(9).toString('hex')}.tmp`;
|
|
211
|
+
try {
|
|
212
|
+
// flag 'wx' (O_CREAT|O_EXCL) — never follow or clobber a pre-planted file/symlink at the temp path; a
|
|
213
|
+
// colliding name fails the write instead. Create owner-only (0o600) so the patched body — which may hold
|
|
214
|
+
// a secret from a sensitive source file — is never briefly world-readable in the window before chmod sets
|
|
215
|
+
// the original's real mode. (This temp pattern is new to shell_edit, so it carries its own hardening.)
|
|
216
|
+
await fs.writeFile(tmp, patched, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
217
|
+
await fs.chmod(tmp, stat.mode & 0o777);
|
|
218
|
+
await fs.rename(tmp, resolved);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const removed = oldText.split('\n').length - 1;
|
|
225
|
+
const added = newText.split('\n').length - 1;
|
|
226
|
+
return `edited ${resolved}: 1 replacement (-${removed}/+${added} lines)`;
|
|
227
|
+
}
|
|
228
|
+
|
|
130
229
|
// Probe the first 1KB for NUL bytes to skip binary files in grep walks.
|
|
131
230
|
/** @param {string} filePath */
|
|
132
231
|
async function isProbablyText(filePath) {
|
|
@@ -453,6 +552,30 @@ function createShellTools() {
|
|
|
453
552
|
execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) =>
|
|
454
553
|
writeFile(/** @type {any} */ (args)),
|
|
455
554
|
},
|
|
555
|
+
{
|
|
556
|
+
name: 'shell_edit',
|
|
557
|
+
description: 'Replace an exact, unique text span in a file — the surgical alternative to shell_write, which ' +
|
|
558
|
+
'rewrites the ENTIRE file. Give oldText (the exact text to replace — it must occur EXACTLY ONCE, so quote ' +
|
|
559
|
+
'enough surrounding lines to be unique) and newText (its replacement; pass "" to delete). Matched literally ' +
|
|
560
|
+
'(no regex; whitespace and indentation are significant). Returns a compact "edited <path>: 1 replacement" ' +
|
|
561
|
+
'receipt, never the file body. If oldText matches 0 or 2+ times the file is left unchanged and the reason is ' +
|
|
562
|
+
'returned so you can re-anchor. The file must already exist. No shell — gate by path with an fs.writeScope ' +
|
|
563
|
+
'policy (translate to {type:"edit"}).',
|
|
564
|
+
parameters: {
|
|
565
|
+
type: 'object',
|
|
566
|
+
properties: {
|
|
567
|
+
path: { type: 'string', description: 'File to edit. ~ expands to home. The file must already exist.' },
|
|
568
|
+
oldText: { type: 'string', description: 'Exact text to find — must occur exactly once. Quote surrounding lines to disambiguate. Matched literally (no regex); whitespace and indentation are significant.' },
|
|
569
|
+
newText: { type: 'string', description: 'Replacement text, inserted verbatim (a literal splice — $ is not special). Pass "" to delete the anchored text. Required — a call without it is REJECTED, not treated as a deletion.' },
|
|
570
|
+
maxBytes: { type: 'integer', description: 'Reject if the resulting file would exceed this many bytes (default 5242880).' },
|
|
571
|
+
},
|
|
572
|
+
required: ['path', 'oldText', 'newText'],
|
|
573
|
+
},
|
|
574
|
+
// The args are model-authored and UNTRUSTED — oldText/newText may be absent (an output-token-capped
|
|
575
|
+
// generation), so the boundary type stays loose and editFile enforces the BA-4 contract at runtime.
|
|
576
|
+
execute: async (/** @type {{path: string, oldText?: string, newText?: string, maxBytes?: number}} */ args) =>
|
|
577
|
+
editFile(/** @type {any} */ (args)),
|
|
578
|
+
},
|
|
456
579
|
{
|
|
457
580
|
name: 'shell_run',
|
|
458
581
|
description: 'Run a command with an argv array (no shell, no interpolation) and return {stdout, stderr, code, timedOut}. Use this when a policy allowlist needs to match on argv[0] — no shell metacharacter injection is possible. Default timeout 30s, max output 1MB.',
|
|
@@ -493,4 +616,4 @@ function createShellTools() {
|
|
|
493
616
|
return { tools };
|
|
494
617
|
}
|
|
495
618
|
|
|
496
|
-
module.exports = { createShellTools, _grepCore, _writeFile: writeFile };
|
|
619
|
+
module.exports = { createShellTools, _grepCore, _writeFile: writeFile, _editFile: editFile };
|