@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.
- package/LICENSE +201 -0
- package/dist/config.d.ts +511 -0
- package/dist/config.js +708 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/compact.d.ts +8 -0
- package/dist/lib/compact.js +179 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/miniWindowGeometry.d.ts +17 -0
- package/dist/lib/miniWindowGeometry.js +59 -0
- package/dist/lib/redact.d.ts +1 -0
- package/dist/lib/redact.js +34 -0
- package/dist/lib/security.d.ts +72 -0
- package/dist/lib/security.js +376 -0
- package/dist/tools/agent.d.ts +4 -0
- package/dist/tools/agent.js +22 -0
- package/dist/tools/context.d.ts +30 -0
- package/dist/tools/context.js +8 -0
- package/dist/tools/edit.d.ts +27 -0
- package/dist/tools/edit.js +161 -0
- package/dist/tools/fs.d.ts +93 -0
- package/dist/tools/fs.js +215 -0
- package/dist/tools/search.d.ts +48 -0
- package/dist/tools/search.js +118 -0
- package/dist/tools/shell.d.ts +7 -0
- package/dist/tools/shell.js +37 -0
- package/dist/tools/subagent.d.ts +2 -0
- package/dist/tools/subagent.js +3 -0
- package/dist/tools/terminal.d.ts +44 -0
- package/dist/tools/terminal.js +100 -0
- package/dist/tools/todo.d.ts +2 -0
- package/dist/tools/todo.js +3 -0
- package/dist/tools/tools.d.ts +236 -0
- package/dist/tools/tools.js +42 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.js +1 -0
- package/package.json +51 -0
|
@@ -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,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
|
+
}
|