zames_pro 2.66.0 → 2.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -106
- package/README.md +484 -0
- package/README.ru.md +469 -0
- package/dist/attachments.js +0 -17
- package/dist/context.js +0 -3
- package/dist/extraTools.js +10 -11
- package/dist/fsutil.js +38 -1
- package/dist/gitTools.js +83 -26
- package/dist/index.js +18 -18
- package/dist/input.js +134 -30
- package/dist/mcp.js +49 -10
- package/dist/permissions.js +40 -1
- package/dist/run-task.js +6 -0
- package/dist/sandbox.js +49 -0
- package/dist/scheduler.js +8 -1
- package/dist/self-review.js +37 -17
- package/dist/shell.js +74 -32
- package/dist/tools.js +40 -20
- package/dist/transcript.js +15 -2
- package/dist/web.js +73 -33
- package/package.json +2 -1
- package/scripts/build-readme.mts +129 -0
- package/scripts/coverage-gate.mjs +4 -0
- package/scripts/release.mts +12 -1
package/dist/permissions.js
CHANGED
|
@@ -63,12 +63,51 @@ function safeRegex(source) {
|
|
|
63
63
|
// A file path may arrive in any of these keys depending on the tool.
|
|
64
64
|
// We collect them all and match the path regex against ANY of them.
|
|
65
65
|
const PATH_KEYS = ['path', 'file_path', 'notebook_path', 'from', 'to'];
|
|
66
|
+
// Best-effort check whether a string looks like a filesystem path. Used for
|
|
67
|
+
// tools whose arguments are not covered by PATH_KEYS (MCP tools take arbitrary
|
|
68
|
+
// arg names from their JSON Schema), so a `path` rule is not silently
|
|
69
|
+
// bypassed just because the tool named the field differently.
|
|
70
|
+
function looksLikePath(v) {
|
|
71
|
+
return /[\\/]/.test(v) || /^\.[.\\/]/.test(v) || /^[A-Za-z]:[\\/]/.test(v);
|
|
72
|
+
}
|
|
73
|
+
// Collect every path-ish string from a tool call's arguments. ApplyPatch keeps
|
|
74
|
+
// the paths INSIDE the patch text (not in a `path` key), and MCP tools use
|
|
75
|
+
// arbitrary names; both used to slip past a `{path: ...}` rule.
|
|
76
|
+
export function collectPathCandidates(tool, args) {
|
|
77
|
+
const a = (args ?? {});
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const k of PATH_KEYS) {
|
|
80
|
+
if (typeof a[k] === 'string')
|
|
81
|
+
out.push(a[k]);
|
|
82
|
+
}
|
|
83
|
+
if (tool === 'ApplyPatch') {
|
|
84
|
+
// Parse the patch and pull the file headers out of it.
|
|
85
|
+
const text = typeof a['patch'] === 'string'
|
|
86
|
+
? a['patch']
|
|
87
|
+
: typeof a['patch_base64'] === 'string'
|
|
88
|
+
? Buffer.from(a['patch_base64'], 'base64').toString('utf-8')
|
|
89
|
+
: '';
|
|
90
|
+
const re = /^\*\*\*\s*(?:Add|Update|Delete) File:\s*(.+?)\s*$/gm;
|
|
91
|
+
let m;
|
|
92
|
+
while ((m = re.exec(text)))
|
|
93
|
+
out.push(m[1]);
|
|
94
|
+
}
|
|
95
|
+
// MCP tools (and any unknown tool): scan the string values for path-looking
|
|
96
|
+
// entries. Conservative — only values that clearly look like paths.
|
|
97
|
+
if (tool.includes('__')) {
|
|
98
|
+
for (const v of Object.values(a)) {
|
|
99
|
+
if (typeof v === 'string' && looksLikePath(v))
|
|
100
|
+
out.push(v);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
66
105
|
export function decidePermission(policy, tool, args) {
|
|
67
106
|
if (!policy)
|
|
68
107
|
return { action: 'allow', reason: 'no policy' };
|
|
69
108
|
const a = (args ?? {});
|
|
70
109
|
const command = typeof a['command'] === 'string' ? a['command'] : '';
|
|
71
|
-
const paths =
|
|
110
|
+
const paths = collectPathCandidates(tool, args);
|
|
72
111
|
for (const r of policy.rules) {
|
|
73
112
|
if (r.tool) {
|
|
74
113
|
const re = safeRegex(r.tool);
|
package/dist/run-task.js
CHANGED
|
@@ -276,6 +276,9 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
276
276
|
},
|
|
277
277
|
});
|
|
278
278
|
try {
|
|
279
|
+
// Set when the loop ends without a real answer (iteration limit / watchdog)
|
|
280
|
+
// so the one-shot mode can exit non-zero. See N5.
|
|
281
|
+
let failure = null;
|
|
279
282
|
let next = {
|
|
280
283
|
text: taskText,
|
|
281
284
|
attachments,
|
|
@@ -363,6 +366,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
363
366
|
outcome.startsWith('ask() watchdog'))) {
|
|
364
367
|
ui.warning(outcome);
|
|
365
368
|
transcript?.log('agent_no_answer', { outcome });
|
|
369
|
+
failure = outcome;
|
|
366
370
|
}
|
|
367
371
|
// T7: one summary line per task (duration + tool calls + tokens spent).
|
|
368
372
|
// Printed even on an abort, so the operator sees what happened.
|
|
@@ -428,6 +432,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
428
432
|
sendSystemPrompt: false,
|
|
429
433
|
};
|
|
430
434
|
}
|
|
435
|
+
return failure ? { ok: false, error: failure } : { ok: true };
|
|
431
436
|
}
|
|
432
437
|
catch (e) {
|
|
433
438
|
ui.stop();
|
|
@@ -435,6 +440,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
435
440
|
if (debug)
|
|
436
441
|
console.error(e.stack);
|
|
437
442
|
transcript?.log('agent_error', { error: e.message });
|
|
443
|
+
return { ok: false, error: e.message };
|
|
438
444
|
}
|
|
439
445
|
finally {
|
|
440
446
|
stopWatching();
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Resolve a path against the sandbox root and reject anything that leaves it.
|
|
4
|
+
// The LEXICAL check (comparing the resolved path with the root) is not
|
|
5
|
+
// enough: a symlink inside the project can point outside it, and the
|
|
6
|
+
// lexical path would still look inside. Therefore the real path
|
|
7
|
+
// (fs.realpath, which follows links) is compared too. For a path that
|
|
8
|
+
// does not exist yet (writing a new file) the nearest existing ancestor
|
|
9
|
+
// is resolved and the remainder is appended on top.
|
|
10
|
+
function realPathNearest(target) {
|
|
11
|
+
let cur = target;
|
|
12
|
+
const tail = [];
|
|
13
|
+
while (true) {
|
|
14
|
+
try {
|
|
15
|
+
return path.join(fs.realpathSync(cur), ...tail.slice().reverse());
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// not found (or a broken link) -> climb to the parent
|
|
19
|
+
}
|
|
20
|
+
const parent = path.dirname(cur);
|
|
21
|
+
if (parent === cur)
|
|
22
|
+
return target;
|
|
23
|
+
tail.push(path.basename(cur));
|
|
24
|
+
cur = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Return the absolute path of `p` within `root`, or throw. The check is
|
|
29
|
+
* double: lexical (catches `../` traversal) and real (catches a symlink
|
|
30
|
+
* escape). Pure except for the fs.realpathSync calls, which are sync on
|
|
31
|
+
* purpose: the callers are sync tool handlers and turning them async would
|
|
32
|
+
* just add noise.
|
|
33
|
+
*/
|
|
34
|
+
export function safePath(root, p) {
|
|
35
|
+
const resolved = path.resolve(root, p);
|
|
36
|
+
// startsWith(root) would let through sibling paths with a common
|
|
37
|
+
// prefix (C:\work\proj vs C:\work\proj-old). We compute via relative().
|
|
38
|
+
const rel = path.relative(root, resolved);
|
|
39
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
40
|
+
throw new Error(`Access outside the working directory is forbidden: ${p}`);
|
|
41
|
+
}
|
|
42
|
+
const realRoot = realPathNearest(path.resolve(root));
|
|
43
|
+
const realTarget = realPathNearest(resolved);
|
|
44
|
+
const realRel = path.relative(realRoot, realTarget);
|
|
45
|
+
if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
|
|
46
|
+
throw new Error(`Access outside the working directory is forbidden: ${p}`);
|
|
47
|
+
}
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
package/dist/scheduler.js
CHANGED
|
@@ -100,6 +100,8 @@ export function cronMatches(expr, date) {
|
|
|
100
100
|
// Next epoch ms at which the expression fires, strictly after `fromMs`. Scans
|
|
101
101
|
// minute by minute (bounded to ~4 years); a one-time cost when a job is
|
|
102
102
|
// created/rescheduled, and the field checks reject almost every minute fast.
|
|
103
|
+
// The parsed fields are REUSED across the scan: calling cronMatches() re-parsed
|
|
104
|
+
// the expression on every minute (millions of parses for a far-future date).
|
|
103
105
|
export function nextCronTime(expr, fromMs) {
|
|
104
106
|
const c = parseCron(expr);
|
|
105
107
|
if (!c)
|
|
@@ -109,8 +111,13 @@ export function nextCronTime(expr, fromMs) {
|
|
|
109
111
|
d.setMinutes(d.getMinutes() + 1);
|
|
110
112
|
const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
|
|
111
113
|
while (d.getTime() <= limit) {
|
|
112
|
-
if (
|
|
114
|
+
if (c.minute.has(d.getMinutes()) &&
|
|
115
|
+
c.hour.has(d.getHours()) &&
|
|
116
|
+
c.dom.has(d.getDate()) &&
|
|
117
|
+
c.month.has(d.getMonth() + 1) &&
|
|
118
|
+
c.dow.has(d.getDay())) {
|
|
113
119
|
return d.getTime();
|
|
120
|
+
}
|
|
114
121
|
d.setMinutes(d.getMinutes() + 1);
|
|
115
122
|
}
|
|
116
123
|
return null;
|
package/dist/self-review.js
CHANGED
|
@@ -48,18 +48,40 @@ async function loadFresh() {
|
|
|
48
48
|
async function ensureDir(p) {
|
|
49
49
|
await fs.mkdir(p, { recursive: true });
|
|
50
50
|
}
|
|
51
|
+
// Recursively list every .ts file under `dir`, as paths RELATIVE to `dir`
|
|
52
|
+
// (POSIX separators). self-review used to read only the top level of src/, so
|
|
53
|
+
// files in subfolders (src/i18n/, src/input/) never made it into a snapshot —
|
|
54
|
+
// the diff/apply silently ignored them. Recursing keeps the whole tree in the
|
|
55
|
+
// snapshot. `_context` is skipped: it is a read-only copy of configs/scripts,
|
|
56
|
+
// not part of the editable set.
|
|
57
|
+
export async function listTsFiles(dir, base = '') {
|
|
58
|
+
const entries = await fs.readdir(path.join(dir, base), {
|
|
59
|
+
withFileTypes: true,
|
|
60
|
+
});
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
const rel = base ? base + '/' + e.name : e.name;
|
|
64
|
+
if (e.isDirectory()) {
|
|
65
|
+
if (rel === '_context')
|
|
66
|
+
continue;
|
|
67
|
+
out.push(...(await listTsFiles(dir, rel)));
|
|
68
|
+
}
|
|
69
|
+
else if (e.name.endsWith('.ts')) {
|
|
70
|
+
out.push(rel);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
51
75
|
async function copyDirJsFiles(from, to) {
|
|
52
76
|
await ensureDir(to);
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
await fs.writeFile(path.join(to, e), data, 'utf-8');
|
|
60
|
-
copied.push(e);
|
|
77
|
+
const rels = await listTsFiles(from);
|
|
78
|
+
for (const rel of rels) {
|
|
79
|
+
const data = await fs.readFile(path.join(from, rel), 'utf-8');
|
|
80
|
+
const dest = path.join(to, rel);
|
|
81
|
+
await ensureDir(path.dirname(dest));
|
|
82
|
+
await fs.writeFile(dest, data, 'utf-8');
|
|
61
83
|
}
|
|
62
|
-
return
|
|
84
|
+
return rels;
|
|
63
85
|
}
|
|
64
86
|
// Root files and folders a review often needs but that are NOT part of the
|
|
65
87
|
// editable src/ set (scripts, lint/ts configs). They are copied into a
|
|
@@ -176,11 +198,9 @@ export async function selfDiff({ name, locale, }) {
|
|
|
176
198
|
if (!snapStat)
|
|
177
199
|
throw new Error(t('self.snapshot_not_found', { dir: snapDir }));
|
|
178
200
|
const { unifiedDiff, colorDiff } = await import('./diff.js');
|
|
179
|
-
const files = await
|
|
201
|
+
const files = await listTsFiles(snapDir);
|
|
180
202
|
let anyDiff = false;
|
|
181
203
|
for (const f of files) {
|
|
182
|
-
if (!f.endsWith('.ts'))
|
|
183
|
-
continue;
|
|
184
204
|
const orig = await fs
|
|
185
205
|
.readFile(path.join(SRC_DIR, f), 'utf-8')
|
|
186
206
|
.catch(() => '');
|
|
@@ -209,7 +229,7 @@ export async function selfApply({ name, locale, }) {
|
|
|
209
229
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
210
230
|
const backupDir = path.join(SNAP_ROOT, `backup-before-apply-${stamp}`);
|
|
211
231
|
await copyDirJsFiles(SRC_DIR, backupDir);
|
|
212
|
-
const files = await
|
|
232
|
+
const files = await listTsFiles(snapDir);
|
|
213
233
|
const jsFiles = files.filter((f) => f.endsWith('.ts'));
|
|
214
234
|
if (jsFiles.length === 0) {
|
|
215
235
|
throw new Error(t('self.apply_no_ts', { dir: snapDir }));
|
|
@@ -217,7 +237,9 @@ export async function selfApply({ name, locale, }) {
|
|
|
217
237
|
const applied = [];
|
|
218
238
|
for (const f of jsFiles) {
|
|
219
239
|
const data = await fs.readFile(path.join(snapDir, f), 'utf-8');
|
|
220
|
-
|
|
240
|
+
const dest = path.join(SRC_DIR, f);
|
|
241
|
+
await ensureDir(path.dirname(dest));
|
|
242
|
+
await fs.writeFile(dest, data, 'utf-8');
|
|
221
243
|
applied.push(f);
|
|
222
244
|
}
|
|
223
245
|
console.log(theme.assistant('\n' + t('self.applied', { n: applied.length })));
|
|
@@ -265,11 +287,9 @@ export async function selfList({ locale, } = {}) {
|
|
|
265
287
|
}
|
|
266
288
|
// ---------- helpers ----------
|
|
267
289
|
async function diffFiles(origDir, newDir) {
|
|
268
|
-
const files = await
|
|
290
|
+
const files = await listTsFiles(newDir).catch(() => []);
|
|
269
291
|
const changed = [];
|
|
270
292
|
for (const f of files) {
|
|
271
|
-
if (!f.endsWith('.ts'))
|
|
272
|
-
continue;
|
|
273
293
|
const a = await fs.readFile(path.join(origDir, f), 'utf-8').catch(() => '');
|
|
274
294
|
const b = await fs.readFile(path.join(newDir, f), 'utf-8').catch(() => '');
|
|
275
295
|
if (a !== b)
|
package/dist/shell.js
CHANGED
|
@@ -1,25 +1,77 @@
|
|
|
1
|
-
import { exec } from 'child_process';
|
|
1
|
+
import { exec, execFile } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
3
4
|
// Shared shell runner for the Bash tool and the operator's `!command` escape.
|
|
4
5
|
// Kept in its own module so both call sites use the SAME sandbox guard and the
|
|
5
6
|
// SAME abort wiring; a second, ad-hoc runner would drift from the real one.
|
|
7
|
+
// Quote one argument for POSIX sh: wrap in single quotes and turn every inner
|
|
8
|
+
// single quote into '\''. Inside single quotes sh expands NOTHING, so a value
|
|
9
|
+
// carrying $(...), backticks or $VAR is passed through literally. A plain
|
|
10
|
+
// JSON.stringify is NOT a safe substitute: it produces double quotes, inside
|
|
11
|
+
// which sh still performs command substitution.
|
|
12
|
+
export function shellQuote(arg) {
|
|
13
|
+
return "'" + String(arg).replace(/'/g, "'\\''") + "'";
|
|
14
|
+
}
|
|
6
15
|
// Sandbox (option A): don't let a shell command go above the project root.
|
|
7
16
|
// This is a protective barrier, not full OS isolation.
|
|
8
17
|
export function assertCommandInsideRoot(root, command) {
|
|
9
18
|
const cmd = String(command || '');
|
|
10
|
-
|
|
19
|
+
// eval re-parses its argument, so a `cd` hidden inside a string is invisible
|
|
20
|
+
// to the static scan; refusing eval together with cd is the honest answer
|
|
21
|
+
// instead of pretending the barrier still holds.
|
|
22
|
+
if (/\beval\b/.test(cmd) && /\bcd\b/.test(cmd)) {
|
|
23
|
+
throw new Error('Sandbox: eval with cd is forbidden');
|
|
24
|
+
}
|
|
25
|
+
// `(` and backticks open a subshell / command substitution where a `cd` is
|
|
26
|
+
// still executed; include them in the prefix so the scan sees it.
|
|
27
|
+
const cdRe = /(?:^|[;&|(\s`])(?:cd|pushd)\s+([^;&|)]+)/gi;
|
|
11
28
|
let m;
|
|
12
29
|
while ((m = cdRe.exec(cmd))) {
|
|
13
|
-
const raw = m[1].trim();
|
|
30
|
+
const raw = m[1].trim().replace(/^["']|["']$/g, '');
|
|
14
31
|
if (!raw || raw === '-')
|
|
15
32
|
continue;
|
|
16
|
-
|
|
33
|
+
// sh expands ~ and $HOME AFTER this check, so expand them here as well —
|
|
34
|
+
// otherwise `cd ~` passes the guard and then leaves the root.
|
|
35
|
+
const expanded = raw
|
|
36
|
+
.replace(/^~(?=$|[/\\])/, os.homedir())
|
|
37
|
+
.replace(/\$\{?HOME\}?/g, os.homedir());
|
|
38
|
+
const target = path.resolve(root, expanded);
|
|
17
39
|
const rel = path.relative(root, target);
|
|
18
40
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
19
41
|
throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
|
|
20
42
|
}
|
|
21
43
|
}
|
|
22
44
|
}
|
|
45
|
+
function formatExecResult(err, stdout, stderr, timeout, okExitCodes) {
|
|
46
|
+
const out = (stdout || '').toString();
|
|
47
|
+
const errStr = (stderr || '').toString();
|
|
48
|
+
if (!err ||
|
|
49
|
+
(typeof err.code === 'number' && okExitCodes.includes(err.code))) {
|
|
50
|
+
const combined = (out + errStr).trim();
|
|
51
|
+
return combined || '(command produced no output)';
|
|
52
|
+
}
|
|
53
|
+
const parts = [];
|
|
54
|
+
if (err.killed) {
|
|
55
|
+
parts.push('\u23f1 Timeout after ' + timeout + 'ms \u2014 process killed.');
|
|
56
|
+
}
|
|
57
|
+
else if (err.code === 'ABORT_ERR') {
|
|
58
|
+
parts.push('(aborted by the operator)');
|
|
59
|
+
}
|
|
60
|
+
else if (err.code !== undefined && err.code !== null) {
|
|
61
|
+
parts.push('Exit code: ' + err.code);
|
|
62
|
+
}
|
|
63
|
+
else if (err.signal) {
|
|
64
|
+
parts.push('Killed by signal: ' + err.signal);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
parts.push('Error: ' + err.message);
|
|
68
|
+
}
|
|
69
|
+
if (out.trim())
|
|
70
|
+
parts.push('stdout:\n' + out.trim());
|
|
71
|
+
if (errStr.trim())
|
|
72
|
+
parts.push('stderr:\n' + errStr.trim());
|
|
73
|
+
return parts.join('\n');
|
|
74
|
+
}
|
|
23
75
|
export function runShell(workdir, command, timeout = 30_000, signal) {
|
|
24
76
|
return new Promise((resolve) => {
|
|
25
77
|
const options = {
|
|
@@ -39,34 +91,24 @@ export function runShell(workdir, command, timeout = 30_000, signal) {
|
|
|
39
91
|
options.shell = '/bin/sh';
|
|
40
92
|
}
|
|
41
93
|
exec(command, options, (err, stdout, stderr) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
parts.push('Killed by signal: ' + err.signal);
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
parts.push('Error: ' + err.message);
|
|
64
|
-
}
|
|
65
|
-
if (out.trim())
|
|
66
|
-
parts.push('stdout:\n' + out.trim());
|
|
67
|
-
if (errStr.trim())
|
|
68
|
-
parts.push('stderr:\n' + errStr.trim());
|
|
69
|
-
resolve(parts.join('\n'));
|
|
94
|
+
resolve(formatExecResult(err, stdout, stderr, timeout, []));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// Run an executable directly with an argument ARRAY (no shell). Use this
|
|
99
|
+
// whenever an argument comes from the model: the shell never sees it, so
|
|
100
|
+
// $(...), backticks and quotes cannot be interpreted as commands.
|
|
101
|
+
export function runFile(workdir, file, args, timeout = 30_000, signal, okExitCodes = []) {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
execFile(file, args, {
|
|
104
|
+
cwd: workdir,
|
|
105
|
+
timeout,
|
|
106
|
+
maxBuffer: 1024 * 1024 * 8,
|
|
107
|
+
windowsHide: true,
|
|
108
|
+
env: { ...process.env },
|
|
109
|
+
signal,
|
|
110
|
+
}, (err, stdout, stderr) => {
|
|
111
|
+
resolve(formatExecResult(err, stdout, stderr, timeout, okExitCodes));
|
|
70
112
|
});
|
|
71
113
|
});
|
|
72
114
|
}
|
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { assertCommandInsideRoot, runShell } from './shell.js';
|
|
3
|
+
import { assertCommandInsideRoot, runFile, runShell } from './shell.js';
|
|
4
|
+
import { safePath } from './sandbox.js';
|
|
4
5
|
import { createGitTools } from './gitTools.js';
|
|
5
6
|
import { createWebTools } from './web.js';
|
|
6
7
|
import { createExtraTools } from './extraTools.js';
|
|
@@ -10,16 +11,7 @@ import { createExtraTools } from './extraTools.js';
|
|
|
10
11
|
// those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
|
|
11
12
|
export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
|
|
12
13
|
const root = path.resolve(workdir);
|
|
13
|
-
const safe = (p) =>
|
|
14
|
-
const resolved = path.resolve(root, p);
|
|
15
|
-
// startsWith(root) would let through sibling paths with a common prefix
|
|
16
|
-
// (C:\work\proj vs C:\work\proj-old). We compute via relative().
|
|
17
|
-
const rel = path.relative(root, resolved);
|
|
18
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
19
|
-
throw new Error(`Access outside the working directory is forbidden: ${p}`);
|
|
20
|
-
}
|
|
21
|
-
return resolved;
|
|
22
|
-
};
|
|
14
|
+
const safe = (p) => safePath(root, p);
|
|
23
15
|
// Extracts text from content/content_base64. base64 is needed because the
|
|
24
16
|
// channel that delivers the model's answer may corrupt characters ($,
|
|
25
17
|
// backslashes, newlines). base64 consists only of [A-Za-z0-9+/=] and is not
|
|
@@ -124,7 +116,9 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
|
|
|
124
116
|
}
|
|
125
117
|
if (undo)
|
|
126
118
|
await undo.backup(file);
|
|
127
|
-
|
|
119
|
+
// Functional replacement: a string replacer interprets $-patterns
|
|
120
|
+
// ($&, $$, $1) inside newStr and silently corrupts the written text.
|
|
121
|
+
content = content.replace(oldStr, () => newStr);
|
|
128
122
|
await fs.writeFile(file, content, 'utf-8');
|
|
129
123
|
return `Edited: ${p}`;
|
|
130
124
|
},
|
|
@@ -182,17 +176,18 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
|
|
|
182
176
|
.map((s) => s.trim())
|
|
183
177
|
.filter(Boolean);
|
|
184
178
|
const mode = String(output || 'content').toLowerCase();
|
|
179
|
+
// The pattern and the target come from the model, so they are passed
|
|
180
|
+
// as an execFile ARGUMENT ARRAY: the shell never parses them and a
|
|
181
|
+
// pattern carrying $(...)/backticks cannot run as a command (this
|
|
182
|
+
// used to be a sandbox escape). Exit code 1 = no matches, not an error.
|
|
185
183
|
if (process.platform === 'win32') {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
return
|
|
184
|
+
const args = ['/s', '/n', '/r', '/c:' + pat];
|
|
185
|
+
args.push(searchPath ? target + '\\*' : '*');
|
|
186
|
+
return runFile(workdir, 'findstr', args, 30_000, undefined, [1]);
|
|
189
187
|
}
|
|
190
188
|
const flags = mode === 'files_only' ? '-rlE' : mode === 'count' ? '-rcE' : '-rnE';
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
.join('');
|
|
194
|
-
return runShell(workdir, `grep ${flags}${includeArg} ${JSON.stringify(pat)} ` +
|
|
195
|
-
`${JSON.stringify(target)} || true`);
|
|
189
|
+
const args = [flags, ...incs.map((g) => '--include=' + g), pat, target];
|
|
190
|
+
return runFile(workdir, 'grep', args, 30_000, undefined, [1]);
|
|
196
191
|
},
|
|
197
192
|
},
|
|
198
193
|
];
|
|
@@ -233,3 +228,28 @@ export const MUTATING_TOOLS = new Set([
|
|
|
233
228
|
export function filterToolsForReadOnly(tools) {
|
|
234
229
|
return tools.filter((t) => !MUTATING_TOOLS.has(t.name));
|
|
235
230
|
}
|
|
231
|
+
// MCP tool names are opaque (server + '__' + tool), so the exact-name
|
|
232
|
+
// MUTATING_TOOLS set cannot classify them. Instead we match the common
|
|
233
|
+
// mutating VERBS in the tool name. It is a heuristic deny-list, not a proof:
|
|
234
|
+
// an exotic server could name a mutating tool innocuously. The honest default
|
|
235
|
+
// in read-only mode is therefore to drop everything that looks like a writer.
|
|
236
|
+
const MCP_MUTATING_RE = /(^|_)(click|type|navigate|press|upload|select|drag|fill|hover|evaluate|run_code|run_|handle_dialog|key|screenshot|write|create|delete|update|set|put|post|patch|execute|exec|insert|remove|drop|restart|stop|start|install|kill|move|rename|copy|mkdir|rmdir|touch|chmod|chown)(_|$)/;
|
|
237
|
+
export function isMutatingMcpTool(name) {
|
|
238
|
+
// Strip the server prefix and normalize separators to underscores so the
|
|
239
|
+
// verb matcher sees "browser_click" -> "click".
|
|
240
|
+
const tail = String(name).split('__').pop() || String(name);
|
|
241
|
+
const norm = tail.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
242
|
+
return MCP_MUTATING_RE.test('_' + norm + '_');
|
|
243
|
+
}
|
|
244
|
+
// Merge MCP tools into the base set. In read-only (plan) mode the MCP tools
|
|
245
|
+
// must be filtered too: an MCP server (e.g. @playwright/mcp) exposes mutating
|
|
246
|
+
// tools (click/type/navigate) that would otherwise defeat the read-only
|
|
247
|
+
// promise. This is a SEPARATE helper (not ad-hoc push sites) so the filtering
|
|
248
|
+
// cannot be forgotten at one of the four call sites. Pure; unit-tested.
|
|
249
|
+
export function mergeTools(base, mcp, readOnly = false) {
|
|
250
|
+
const all = mcp || [];
|
|
251
|
+
const extra = readOnly
|
|
252
|
+
? all.filter((t) => !MUTATING_TOOLS.has(t.name) && !isMutatingMcpTool(t.name))
|
|
253
|
+
: all;
|
|
254
|
+
return [...base, ...extra];
|
|
255
|
+
}
|
package/dist/transcript.js
CHANGED
|
@@ -53,8 +53,21 @@ export class Transcript {
|
|
|
53
53
|
}
|
|
54
54
|
catch { }
|
|
55
55
|
}
|
|
56
|
+
// Flush the stream and resolve once the data reached the file. The callers
|
|
57
|
+
// run on process.exit paths (one-shot --task, browser error, clean exit), and
|
|
58
|
+
// end() without awaiting the callback lets the process die before the buffer
|
|
59
|
+
// is written — exactly the final events (task result, agent error) one needs
|
|
60
|
+
// for a post-mortem. A no-op promise when there is no stream.
|
|
56
61
|
close() {
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
const stream = this.stream;
|
|
63
|
+
this.stream = null;
|
|
64
|
+
if (!stream)
|
|
65
|
+
return Promise.resolve();
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
stream.end(() => resolve());
|
|
68
|
+
// A write error must not hang the exit; the error listener is installed
|
|
69
|
+
// in the constructor.
|
|
70
|
+
stream.on('error', () => resolve());
|
|
71
|
+
});
|
|
59
72
|
}
|
|
60
73
|
}
|