@termaxjs/web-ai 0.1.1

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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Path-safety guards for AI tool calls.
3
+ *
4
+ * Goals:
5
+ * - Block reads of files that almost always contain secrets (.env*, *.pem,
6
+ * id_rsa*, .aws/credentials, .ssh/, .git/, kube/azure config, etc.).
7
+ * - Block writes/exec into the same set, plus directories where automated
8
+ * mutation is dangerous (system dirs, Windows system dirs).
9
+ *
10
+ * This is a *defense layer*, not a sandbox. The model may still be coaxed
11
+ * into doing something silly within allowed paths — the user-confirmation
12
+ * UI for write/exec is the real safety net. These checks ensure that
13
+ * read tools (which auto-approve) can never silently exfiltrate obvious
14
+ * secrets, and that a single bad approval can't blow up the system.
15
+ *
16
+ * Defense-in-depth notes:
17
+ * - Comparison surface is lowercased *only for matching*. Original path is
18
+ * preserved for basename pattern checks and error messages.
19
+ * - Windows drive prefix (e.g. `C:`) is stripped from the comparison form so
20
+ * Unix-style root prefix checks behave consistently on both platforms.
21
+ * - Protected directories match exact-equal-or-descendant, not raw
22
+ * substring-with-trailing-slash. Bare names (`/Users/me/.ssh`) and
23
+ * case-variants (`/Users/me/.SSH/config` on macOS/Windows case-insensitive
24
+ * filesystems) are caught.
25
+ * - The caller is expected to additionally validate the *canonical* path
26
+ * (post symlink resolution) via `getAiAdapter().native.canonicalize` + a second
27
+ * `checkReadable` pass, since a symlink at an "innocent" path can point
28
+ * into a protected directory.
29
+ */
30
+ const SECRET_BASENAME_PATTERNS = [
31
+ // Match `.env` and `.env.<suffix>` with no required tail anchor — Windows
32
+ // strips trailing dots/spaces at open time and NTFS exposes alternate data
33
+ // streams via `name:stream`, both of which would otherwise slip past a `$`
34
+ // anchored pattern (`.env.`, `.env::$DATA`).
35
+ /^\.env(\..+)?(?:[.\s:]|$)/i,
36
+ /^.*\.pem(?:[.\s:]|$)/i,
37
+ /^.*\.key(?:[.\s:]|$)/i, // private keys
38
+ /^.*\.p12(?:[.\s:]|$)/i,
39
+ /^.*\.pfx(?:[.\s:]|$)/i,
40
+ /^.*\.asc(?:[.\s:]|$)/i, // PGP armored keys
41
+ /^.*\.gpg(?:[.\s:]|$)/i,
42
+ /^.*\.keystore(?:[.\s:]|$)/i,
43
+ /^.*\.jks(?:[.\s:]|$)/i,
44
+ // Match `id_rsa`, `id_rsa.pub`, and common backup/copy patterns like
45
+ // `id_rsa.bak`, `id_rsa_old`, `id_rsa-backup`.
46
+ /^id_(rsa|dsa|ecdsa|ed25519)([._-].*)?(?:[.\s:]|$)/i,
47
+ /^known_hosts(?:[.\s:]|$)/i,
48
+ /^authorized_keys(?:[.\s:]|$)/i,
49
+ /^htpasswd(?:[.\s:]|$)/i,
50
+ /^\.netrc(?:[.\s:]|$)/i,
51
+ /^_netrc(?:[.\s:]|$)/i, // Windows variant
52
+ /^credentials(?:[.\s:]|$)/i, // .aws/credentials, gcloud, etc.
53
+ /^\.pgpass(?:[.\s:]|$)/i,
54
+ /^\.npmrc(?:[.\s:]|$)/i,
55
+ /^\.pypirc(?:[.\s:]|$)/i,
56
+ /^secrets?\.(json|ya?ml|toml|env)(?:[.\s:]|$)/i,
57
+ /^service[-_]?account.*\.json(?:[.\s:]|$)/i, // GCP service account keys
58
+ ];
59
+ /**
60
+ * Protected directories. Matched as **exact path** OR **prefix where the next
61
+ * char is a separator** — never raw substring. Listed without trailing slash;
62
+ * the comparator handles separators.
63
+ */
64
+ const PROTECTED_DIRS = [
65
+ "/.ssh",
66
+ "/.gnupg",
67
+ "/.aws",
68
+ "/.azure",
69
+ "/.kube",
70
+ "/.docker",
71
+ "/.config/gh",
72
+ "/.config/git",
73
+ "/.config/gcloud",
74
+ "/.config/op", // 1Password CLI
75
+ "/.git", // git internals — refusing avoids tools mutating refs/objects
76
+ "/.terraform.d",
77
+ "/library/keychains",
78
+ "/library/cookies",
79
+ // System dirs holding host secrets/PII/process state. Per-PID files under
80
+ // /proc leak env vars and command lines from other processes; /sys exposes
81
+ // kernel state and hardware identifiers. /etc and /private/etc hold global
82
+ // config that frequently contains credentials in basenames the regex won't
83
+ // match (passwd, shadow, master.passwd, *.cnf, *.conf with creds).
84
+ "/etc",
85
+ "/private/etc",
86
+ "/proc",
87
+ "/sys",
88
+ "/var/db",
89
+ "/var/root",
90
+ "/private/var/db",
91
+ "/private/var/root",
92
+ // Windows user profile equivalents (post drive-strip + lowercase).
93
+ "/appdata/roaming/microsoft/credentials",
94
+ "/appdata/local/microsoft/credentials",
95
+ "/appdata/roaming/gcloud",
96
+ ];
97
+ /**
98
+ * Write-only deny prefixes (system locations). Read access is *not* universally
99
+ * blocked — reading `/etc/hosts` is fine; writing to it isn't.
100
+ */
101
+ const WRITE_DENY_PREFIXES = [
102
+ "/etc/",
103
+ "/var/db/",
104
+ "/var/root/",
105
+ "/system/", // case-folded from /System/
106
+ "/library/keychains/",
107
+ "/library/launchagents/",
108
+ "/library/launchdaemons/",
109
+ "/private/etc/",
110
+ "/private/var/db/",
111
+ "/usr/bin/",
112
+ "/usr/sbin/",
113
+ "/usr/local/bin/",
114
+ "/bin/",
115
+ "/sbin/",
116
+ "/boot/",
117
+ // Windows (post drive-strip + lowercase). Note: these block writes to the
118
+ // system drive's Windows / Program Files. Drives are stripped, so any
119
+ // /windows/... etc. matches regardless of drive letter.
120
+ "/windows/",
121
+ "/program files/",
122
+ "/program files (x86)/",
123
+ "/programdata/",
124
+ ];
125
+ function basename(p) {
126
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
127
+ return i >= 0 ? p.slice(i + 1) : p;
128
+ }
129
+ /**
130
+ * Build a normalized *comparison surface* — never used as a real path:
131
+ * - back-slashes -> forward-slashes
132
+ * - strip Windows drive prefix (e.g. `C:`)
133
+ * - strip UNC prefix `//?/`
134
+ * - strip NTFS alternate-data-stream suffix (`name:stream` / `name::$DATA`)
135
+ * from each path segment — Windows reads `foo:stream` as `foo` for our
136
+ * purposes, so the comparison surface should too
137
+ * - strip trailing dots/spaces from each segment — Windows discards these
138
+ * at open time, so `.env.` and `.env ` open `.env`
139
+ * - collapse duplicate slashes
140
+ * - lowercase (so case variants match on case-insensitive filesystems)
141
+ * - drop trailing slash (except for root)
142
+ */
143
+ function comparisonForm(p) {
144
+ let s = p.replace(/\\/g, "/");
145
+ // UNC / extended-length prefix: \\?\C:\... or //?/C:/... → strip up to drive.
146
+ s = s.replace(/^\/\/\?\//, "/");
147
+ // Drive prefix: C:/foo → /foo. Important: do this BEFORE lowercasing so we
148
+ // don't have to special-case "c:" vs "C:".
149
+ s = s.replace(/^[a-zA-Z]:/, "");
150
+ // Strip NTFS alternate-data-stream syntax from each segment. `name:stream`
151
+ // and `name::$DATA` both read the same underlying file from `name`, so
152
+ // they must compare-equal to `name`.
153
+ s = s
154
+ .split("/")
155
+ .map((seg) => {
156
+ const colon = seg.indexOf(":");
157
+ return colon === -1 ? seg : seg.slice(0, colon);
158
+ })
159
+ .join("/");
160
+ // Strip trailing dots/spaces from each segment (Windows behavior).
161
+ s = s
162
+ .split("/")
163
+ .map((seg) => seg.replace(/[.\s]+$/, ""))
164
+ .join("/");
165
+ // Collapse duplicate slashes (//foo → /foo). Preserve a possible leading
166
+ // single slash.
167
+ s = s.replace(/\/{2,}/g, "/");
168
+ s = s.toLowerCase();
169
+ // Drop trailing slash so "/foo/" and "/foo" compare equal.
170
+ if (s.length > 1 && s.endsWith("/"))
171
+ s = s.slice(0, -1);
172
+ return s;
173
+ }
174
+ function isUnderProtected(cmp, dir) {
175
+ // Ensure cmp is prefixed with / so relative paths like '.ssh/config'
176
+ // match protected directory entries like '/.ssh'.
177
+ const norm = cmp.startsWith("/") ? cmp : `/${cmp}`;
178
+ return `${norm}/`.includes(`${dir}/`);
179
+ }
180
+ function describeProtected(dir) {
181
+ // "/.ssh" -> ".ssh", "/.config/gh" -> ".config/gh"
182
+ return dir.replace(/^\//, "");
183
+ }
184
+ export function checkReadable(path) {
185
+ if (typeof path !== "string" || path.length === 0) {
186
+ return { ok: false, reason: "Refused: empty path." };
187
+ }
188
+ // Reject NUL and control bytes in paths — these are never legitimate and
189
+ // are a classic truncation/injection vector.
190
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching C0 controls is the entire point of this security check.
191
+ if (/[\x00-\x1f]/.test(path)) {
192
+ return { ok: false, reason: "Refused: path contains control bytes." };
193
+ }
194
+ const base = basename(path);
195
+ for (const re of SECRET_BASENAME_PATTERNS) {
196
+ if (re.test(base)) {
197
+ return {
198
+ ok: false,
199
+ reason: `Refused: "${base}" matches a sensitive-file pattern.`,
200
+ };
201
+ }
202
+ }
203
+ const cmp = comparisonForm(path);
204
+ for (const dir of PROTECTED_DIRS) {
205
+ if (isUnderProtected(cmp, dir)) {
206
+ return {
207
+ ok: false,
208
+ reason: `Refused: path is inside a protected directory (${describeProtected(dir)}).`,
209
+ };
210
+ }
211
+ }
212
+ return { ok: true };
213
+ }
214
+ export function checkWritable(path) {
215
+ // Writes inherit all read restrictions, plus system-directory blocks.
216
+ const r = checkReadable(path);
217
+ if (!r.ok)
218
+ return r;
219
+ const cmp = comparisonForm(path);
220
+ // Ensure the comparison surface has a leading separator for prefix matching.
221
+ const cmpForPrefix = cmp.startsWith("/") ? cmp : `/${cmp}`;
222
+ for (const prefix of WRITE_DENY_PREFIXES) {
223
+ if (cmpForPrefix.startsWith(prefix) ||
224
+ `${cmpForPrefix}/`.startsWith(prefix)) {
225
+ return {
226
+ ok: false,
227
+ reason: `Refused: writes under "${prefix.replace(/\/$/, "")}" are not allowed.`,
228
+ };
229
+ }
230
+ }
231
+ return { ok: true };
232
+ }
233
+ /**
234
+ * Lightweight heuristic for blocking obviously destructive shell commands
235
+ * even after the user has approved them. The approval UI shows the command
236
+ * verbatim, so the user is the primary gate; this just catches a couple of
237
+ * patterns that almost certainly indicate the model went off the rails.
238
+ */
239
+ /**
240
+ * Two-phase safety check that also defends against symlink traversal: first
241
+ * checks the literal path, then (if it exists) canonicalizes it via the
242
+ * native FS and re-checks the resolved path. A symlink at `./innocent.txt`
243
+ * pointing into `~/.ssh/id_rsa` is caught on the second pass.
244
+ *
245
+ * Returns the canonical path on success so callers can use it for the actual
246
+ * read — avoids TOCTOU between the safety check and the read.
247
+ */
248
+ export async function checkReadableCanonical(path, canonicalize) {
249
+ const initial = checkReadable(path);
250
+ if (!initial.ok)
251
+ return initial;
252
+ let canonical;
253
+ try {
254
+ canonical = await canonicalize(path);
255
+ }
256
+ catch {
257
+ // Path doesn't exist yet — fine for the read tool to surface ENOENT.
258
+ return { ok: true, canonical: path };
259
+ }
260
+ // Always recheck — even when canonicalize returns the same string, the
261
+ // checks themselves can have OS-specific gaps (NTFS streams, trailing
262
+ // dot/space) that warrant a second pass against the comparison form.
263
+ const recheck = checkReadable(canonical);
264
+ if (!recheck.ok)
265
+ return recheck;
266
+ return { ok: true, canonical };
267
+ }
268
+ /**
269
+ * Same pattern as {@link checkReadableCanonical} but for writes. The canonical
270
+ * path is only available if the file already exists — for new-file creates
271
+ * we additionally canonicalize the parent directory.
272
+ */
273
+ export async function checkWritableCanonical(path, canonicalize) {
274
+ const initial = checkWritable(path);
275
+ if (!initial.ok)
276
+ return initial;
277
+ // Try canonicalizing the target itself first.
278
+ try {
279
+ const canonical = await canonicalize(path);
280
+ // Always recheck the canonical form — same rationale as checkReadableCanonical.
281
+ const recheck = checkWritable(canonical);
282
+ if (!recheck.ok)
283
+ return recheck;
284
+ return { ok: true, canonical };
285
+ }
286
+ catch {
287
+ // Target doesn't exist — canonicalize the parent so we still catch a
288
+ // symlinked parent directory (`./project -> /Users/me/.ssh`).
289
+ const lastSep = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
290
+ if (lastSep > 0) {
291
+ const parent = path.slice(0, lastSep);
292
+ const tail = path.slice(lastSep);
293
+ try {
294
+ const canonParent = await canonicalize(parent);
295
+ const recheckParent = checkWritable(canonParent + tail);
296
+ if (!recheckParent.ok)
297
+ return recheckParent;
298
+ return { ok: true, canonical: canonParent + tail };
299
+ }
300
+ catch {
301
+ // Parent doesn't exist either — let the caller surface the actual error.
302
+ }
303
+ }
304
+ return { ok: true, canonical: path };
305
+ }
306
+ }
307
+ export function checkShellCommand(cmd) {
308
+ const c = cmd.trim();
309
+ if (c.length === 0) {
310
+ return { ok: false, reason: "Refused: empty command." };
311
+ }
312
+ // Block C0 controls. CR/LF would let a second statement smuggle past the
313
+ // approval UI, which shows the command as one logical line.
314
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching C0 controls is the entire point of this security check.
315
+ if (/[\x00-\x1f]/.test(c)) {
316
+ return {
317
+ ok: false,
318
+ reason: "Refused: command contains control characters (including CR/LF). Commands must be single-line.",
319
+ };
320
+ }
321
+ // Block Unicode bidi-override and invisible directional marks. These let an
322
+ // attacker craft a command whose visual order (in the approval UI's <pre>
323
+ // block) differs from its logical execution order — a Trojan Source attack.
324
+ // Legitimate shell commands do not need RTL overrides.
325
+ if (/[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/.test(c)) {
326
+ return {
327
+ ok: false,
328
+ reason: "Refused: command contains Unicode bidirectional override characters.",
329
+ };
330
+ }
331
+ // rm -rf / (and variants with quoted /, --no-preserve-root, etc.)
332
+ if (/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive\s+--force|--force\s+--recursive)\s+(['"]?\/['"]?\s*($|;|&|\|))/.test(c)) {
333
+ return {
334
+ ok: false,
335
+ reason: "Refused: command attempts to recursively delete the filesystem root.",
336
+ };
337
+ }
338
+ // rm -rf ~ / $HOME / ${HOME}, with or without a trailing path — wiping the user's home dir
339
+ if (/\brm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+(['"]?(~(\/[^\s'"]*)?|\$\{?HOME\}?(\/[^\s'"]*)?)['"]?)(\s|$|;|&|\|)/.test(c)) {
340
+ return {
341
+ ok: false,
342
+ reason: "Refused: command attempts to recursively delete the home directory.",
343
+ };
344
+ }
345
+ if (/--no-preserve-root/.test(c)) {
346
+ return { ok: false, reason: "Refused: --no-preserve-root is not allowed." };
347
+ }
348
+ // dd to a raw disk device
349
+ if (/\bdd\b[^|]*\bof=\/dev\/(disk|sd|nvme|hd)/i.test(c)) {
350
+ return {
351
+ ok: false,
352
+ reason: "Refused: dd to a block device is not allowed.",
353
+ };
354
+ }
355
+ // mkfs / fdisk / diskutil eraseDisk / parted
356
+ if (/\b(mkfs(\.[a-z0-9]+)?|fdisk|parted)\b/.test(c) ||
357
+ /\bdiskutil\s+erase/i.test(c)) {
358
+ return {
359
+ ok: false,
360
+ reason: "Refused: disk-formatting commands are not allowed.",
361
+ };
362
+ }
363
+ // Fork bomb
364
+ if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/.test(c)) {
365
+ return { ok: false, reason: "Refused: fork-bomb pattern detected." };
366
+ }
367
+ // Pipe-to-shell from network. The user already approves the command, but
368
+ // this combo is overwhelmingly malicious-payload-shaped and worth flagging.
369
+ if (/\b(curl|wget)\b[^|;&]*\|\s*(ba|z|k|d|fi|c)?sh\b/.test(c)) {
370
+ return {
371
+ ok: false,
372
+ reason: "Refused: piping a network download directly into a shell is blocked. Download first, inspect, then run.",
373
+ };
374
+ }
375
+ return { ok: true };
376
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToolContext } from "./context.js";
2
+ export declare function buildManagedAgentTools(ctx: ToolContext): {
3
+ run_subagent: any;
4
+ };
@@ -0,0 +1,22 @@
1
+ import { getAiAdapter } from "../config.js";
2
+ import { tool } from "ai";
3
+ import { z } from "zod";
4
+ export function buildManagedAgentTools(ctx) {
5
+ return {
6
+ run_subagent: tool({
7
+ description: "Spawn a managed subagent.",
8
+ inputSchema: z.object({
9
+ prompt: z.string(),
10
+ }),
11
+ execute: async ({ prompt }) => {
12
+ try {
13
+ const result = await getAiAdapter().spawnManagedAgent({ prompt });
14
+ return result;
15
+ }
16
+ catch (e) {
17
+ return { error: String(e) };
18
+ }
19
+ },
20
+ }),
21
+ };
22
+ }
@@ -0,0 +1,30 @@
1
+ export type ToolContext = {
2
+ /** Active terminal tab cwd, used to resolve relative paths. Null = home. */
3
+ getCwd: () => string | null;
4
+ /** Workspace root (explorer root). Used by tools that operate over the project. */
5
+ getWorkspaceRoot: () => string | null;
6
+ /** Last N lines of the active terminal buffer (or null if not a terminal tab). */
7
+ getTerminalContext: () => string | null;
8
+ isActiveTerminalPrivate: () => boolean;
9
+ /**
10
+ * Type a string into the active terminal at the prompt — without executing.
11
+ * Returns false if there is no active terminal tab to inject into.
12
+ */
13
+ injectIntoActivePty: (text: string) => boolean;
14
+ /** Open a new preview tab (in-app iframe) at the given URL. */
15
+ openPreview: (url: string) => boolean;
16
+ /** Spawn a Claude Code agent in a new terminal tab, bound to this session. */
17
+ spawnAgent: (prompt: string) => {
18
+ tabId: number;
19
+ leafId: number;
20
+ } | null;
21
+ /** Read the terminal scrollback tail of a managed agent's leaf. */
22
+ readAgentOutput: (leafId: number) => string | null;
23
+ readCache: Map<string, {
24
+ size: number;
25
+ hash: number;
26
+ }>;
27
+ /** Active chat session id — used by tools that persist per-session state (todos). */
28
+ getSessionId: () => string | null;
29
+ };
30
+ export declare function resolvePath(rawPath: string, cwd: string | null): string;
@@ -0,0 +1,8 @@
1
+ export function resolvePath(rawPath, cwd) {
2
+ if (rawPath.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(rawPath))
3
+ return rawPath;
4
+ if (!cwd)
5
+ throw new Error(`cannot resolve relative path "${rawPath}": no active terminal cwd. Pass an absolute path.`);
6
+ const sep = cwd.includes("\\") && !cwd.includes("/") ? "\\" : "/";
7
+ return cwd.endsWith(sep) ? `${cwd}${rawPath}` : `${cwd}${sep}${rawPath}`;
8
+ }
@@ -0,0 +1,27 @@
1
+ import { type ToolContext } from "./context.js";
2
+ type EditResult = {
3
+ ok: true;
4
+ replacements: number;
5
+ bytesWritten: number;
6
+ path: string;
7
+ } | {
8
+ error: string;
9
+ path: string;
10
+ };
11
+ export declare function buildEditTools(ctx: ToolContext): {
12
+ readonly edit: import("ai").Tool<{
13
+ path: string;
14
+ old_string: string;
15
+ new_string: string;
16
+ replace_all?: boolean | undefined;
17
+ }, EditResult>;
18
+ readonly multi_edit: import("ai").Tool<{
19
+ path: string;
20
+ edits: {
21
+ old_string: string;
22
+ new_string: string;
23
+ replace_all?: boolean | undefined;
24
+ }[];
25
+ }, EditResult>;
26
+ };
27
+ export {};
@@ -0,0 +1,161 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { getAiAdapter } from "../config.js";
4
+ import { checkWritableCanonical } from "../lib/security.js";
5
+ import { resolvePath } from "./context.js";
6
+ function djb2(s) {
7
+ let h = 5381;
8
+ for (let i = 0; i < s.length; i++)
9
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
10
+ return h >>> 0;
11
+ }
12
+ async function applyEdits(abs, edits, kind, readCache) {
13
+ const r = await getAiAdapter().native.readFile(abs);
14
+ if (r.kind === "binary")
15
+ return { error: "binary file refused", path: abs };
16
+ if (r.kind === "toolarge")
17
+ return { error: `file too large (${r.size} bytes)`, path: abs };
18
+ const original = r.content;
19
+ let content = original;
20
+ let totalReplacements = 0;
21
+ for (const e of edits) {
22
+ if (e.old_string === e.new_string) {
23
+ return {
24
+ error: "old_string and new_string are identical",
25
+ path: abs,
26
+ };
27
+ }
28
+ if (e.old_string.length === 0) {
29
+ return { error: "old_string cannot be empty", path: abs };
30
+ }
31
+ if (e.replace_all) {
32
+ const before = content;
33
+ content = content.split(e.old_string).join(e.new_string);
34
+ const occurrences = (before.length - content.length) /
35
+ (e.old_string.length - e.new_string.length || 1) || 0;
36
+ // Recover count via direct search to avoid divide-by-zero edge cases.
37
+ let n = 0;
38
+ let i = before.indexOf(e.old_string, 0);
39
+ while (i !== -1) {
40
+ n++;
41
+ i = before.indexOf(e.old_string, i + e.old_string.length);
42
+ }
43
+ if (n === 0) {
44
+ return {
45
+ error: `old_string not found: ${JSON.stringify(e.old_string.slice(0, 80))}`,
46
+ path: abs,
47
+ };
48
+ }
49
+ totalReplacements += n;
50
+ void occurrences;
51
+ }
52
+ else {
53
+ const first = content.indexOf(e.old_string);
54
+ if (first === -1) {
55
+ return {
56
+ error: `old_string not found: ${JSON.stringify(e.old_string.slice(0, 80))}`,
57
+ path: abs,
58
+ };
59
+ }
60
+ const second = content.indexOf(e.old_string, first + 1);
61
+ if (second !== -1) {
62
+ return {
63
+ error: "old_string is not unique. Provide more surrounding context, or set replace_all=true.",
64
+ path: abs,
65
+ };
66
+ }
67
+ content =
68
+ content.slice(0, first) +
69
+ e.new_string +
70
+ content.slice(first + e.old_string.length);
71
+ totalReplacements += 1;
72
+ }
73
+ }
74
+ if (getAiAdapter().isPlanActive()) {
75
+ getAiAdapter().enqueuePlanEdit({
76
+ id: crypto.randomUUID(),
77
+ kind,
78
+ path: abs,
79
+ originalContent: original,
80
+ proposedContent: content,
81
+ isNewFile: false,
82
+ });
83
+ return {
84
+ ok: true,
85
+ replacements: totalReplacements,
86
+ bytesWritten: content.length,
87
+ path: abs,
88
+ };
89
+ }
90
+ try {
91
+ await getAiAdapter().native.writeFile(abs, content);
92
+ readCache.set(abs, { size: content.length, hash: djb2(content) });
93
+ return {
94
+ ok: true,
95
+ replacements: totalReplacements,
96
+ bytesWritten: content.length,
97
+ path: abs,
98
+ };
99
+ }
100
+ catch (err) {
101
+ return { error: String(err), path: abs };
102
+ }
103
+ }
104
+ export function buildEditTools(ctx) {
105
+ return {
106
+ edit: tool({
107
+ description: "Replace an exact string in a file. Requires read_file on this path first in the current session — this prevents blind edits. `old_string` must be unique in the file unless `replace_all: true`. Asks for user approval before writing.",
108
+ inputSchema: z.object({
109
+ path: z.string(),
110
+ old_string: z
111
+ .string()
112
+ .describe("Exact substring to replace. Must be unique unless replace_all."),
113
+ new_string: z.string().describe("Replacement substring."),
114
+ replace_all: z.boolean().optional(),
115
+ }),
116
+ needsApproval: true,
117
+ execute: async ({ path, old_string, new_string, replace_all }) => {
118
+ const reqPath = resolvePath(path, ctx.getCwd());
119
+ const safety = await checkWritableCanonical(reqPath, getAiAdapter().native.canonicalize);
120
+ if (!safety.ok)
121
+ return { error: safety.reason, path: reqPath };
122
+ const abs = safety.canonical;
123
+ if (!ctx.readCache.has(abs)) {
124
+ return {
125
+ error: "must call read_file on this path first (read-before-edit invariant).",
126
+ path: abs,
127
+ };
128
+ }
129
+ return applyEdits(abs, [{ old_string, new_string, replace_all }], "edit", ctx.readCache);
130
+ },
131
+ }),
132
+ multi_edit: tool({
133
+ description: "Apply several exact-string replacements to a single file atomically. Each edit is applied in order to the running buffer; if any edit's old_string is missing or non-unique, the whole batch aborts before writing. Requires prior read_file on the path. Asks for user approval before writing.",
134
+ inputSchema: z.object({
135
+ path: z.string(),
136
+ edits: z
137
+ .array(z.object({
138
+ old_string: z.string(),
139
+ new_string: z.string(),
140
+ replace_all: z.boolean().optional(),
141
+ }))
142
+ .min(1),
143
+ }),
144
+ needsApproval: true,
145
+ execute: async ({ path, edits }) => {
146
+ const reqPath = resolvePath(path, ctx.getCwd());
147
+ const safety = await checkWritableCanonical(reqPath, getAiAdapter().native.canonicalize);
148
+ if (!safety.ok)
149
+ return { error: safety.reason, path: reqPath };
150
+ const abs = safety.canonical;
151
+ if (!ctx.readCache.has(abs)) {
152
+ return {
153
+ error: "must call read_file on this path first (read-before-edit invariant).",
154
+ path: abs,
155
+ };
156
+ }
157
+ return applyEdits(abs, edits, "multi_edit", ctx.readCache);
158
+ },
159
+ }),
160
+ };
161
+ }