zames_pro 2.73.2 → 2.73.3

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 CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.73.3] - 2026-10-11
11
+
12
+ ### Fixed
13
+
14
+ - harden SSRF guard, sandbox, git args, mcp lifecycle and editor
15
+
10
16
  ## [2.73.2] - 2026-10-11
11
17
 
12
18
  ### Changed
@@ -197,18 +197,22 @@ export function looksLikeFileName(name) {
197
197
  export async function saveToTemp(tmpDir, name, data) {
198
198
  await fs.mkdir(tmpDir, { recursive: true });
199
199
  const base = sanitize(name);
200
- let target = path.join(tmpDir, base);
201
- if (await fs.stat(target).catch(() => null)) {
202
- const ext = path.extname(base);
203
- const stem = base.slice(0, base.length - ext.length);
204
- let i = 1;
205
- while (await fs.stat(target).catch(() => null)) {
206
- target = path.join(tmpDir, stem + '-' + i + ext);
207
- i++;
200
+ const ext = path.extname(base);
201
+ const stem = base.slice(0, base.length - ext.length);
202
+ // O_EXCL ('wx'): pick the first free name and CREATE it atomically. A
203
+ // stat-then-write raced with another attach and silently overwrote a file.
204
+ for (let i = 0; i < 10_000; i++) {
205
+ const target = path.join(tmpDir, i === 0 ? base : stem + '-' + i + ext);
206
+ try {
207
+ await fs.writeFile(target, data, { flag: 'wx' });
208
+ return target;
209
+ }
210
+ catch (e) {
211
+ if (e.code !== 'EEXIST')
212
+ throw e;
208
213
  }
209
214
  }
210
- await fs.writeFile(target, data);
211
- return target;
215
+ throw new Error('could not find a free temp name for ' + base);
212
216
  }
213
217
  // True if any OS clipboard tool is available on this machine.
214
218
  export function hasClipboardTool() {
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import { execFile } from 'child_process';
5
+ import { randomBytes } from 'crypto';
5
6
  import { writeJsonAtomic } from './fsutil.js';
6
7
  // Checkpoints / rewind (BACKLOG B3).
7
8
  //
@@ -120,7 +121,11 @@ export class CheckpointStore {
120
121
  return null;
121
122
  await this._ensure();
122
123
  const stamp = Date.now();
123
- const file = path.join(this.dir, `${stamp}.tgz`);
124
+ // Two checkpoints in the SAME millisecond would share `${stamp}.tgz` and
125
+ // the second would overwrite the first (losing a snapshot). Add a random
126
+ // suffix so an archive name is unique.
127
+ const uniq = randomBytes(3).toString('hex');
128
+ const file = path.join(this.dir, `${stamp}-${uniq}.tgz`);
124
129
  const res = await runTar(tarCreateArgs(file, workdir), workdir);
125
130
  if (res.code !== 0) {
126
131
  await fs.rm(file, { force: true }).catch(() => { });
@@ -187,7 +192,19 @@ export class CheckpointStore {
187
192
  .rm(path.join(record.workdir, e), { recursive: true, force: true })
188
193
  .catch(() => { });
189
194
  }
190
- await copyTree(tmp, record.workdir);
195
+ // A copy failure here must NOT surface as an uncaught throw that leaves
196
+ // the (already wiped) tree half-restored: report it as a failure so the
197
+ // caller can tell the operator to /rewind from the pre-rewind backup.
198
+ try {
199
+ await copyTree(tmp, record.workdir);
200
+ }
201
+ catch (e) {
202
+ return {
203
+ ok: false,
204
+ reason: 'copy_failed',
205
+ detail: e.message,
206
+ };
207
+ }
191
208
  return { ok: true, record, backup };
192
209
  }
193
210
  finally {
package/dist/config.js CHANGED
@@ -119,6 +119,12 @@ export function providerAskDeadlineMs(cfg) {
119
119
  }
120
120
  function deepMerge(target, source) {
121
121
  for (const key of Object.keys(source)) {
122
+ // A `.zamesrc.json` (possibly shared/copied from elsewhere) could carry a
123
+ // `__proto__` / `constructor` / `prototype` key; assigning those to a
124
+ // plain object mutates Object.prototype (prototype pollution). Skip them.
125
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
126
+ continue;
127
+ }
122
128
  if (source[key] &&
123
129
  typeof source[key] === 'object' &&
124
130
  !Array.isArray(source[key])) {
package/dist/context.js CHANGED
@@ -211,7 +211,17 @@ export function skillBody(raw) {
211
211
  const end = norm.indexOf(NL + '---', 3);
212
212
  if (end === -1)
213
213
  return norm.trim();
214
- return norm.slice(norm.indexOf(NL, end + 1) + 1).trim();
214
+ // The closing `---` may be the LAST line with no trailing newline; then
215
+ // indexOf(NL, …) is -1 and slice(0) returned the WHOLE file (frontmatter
216
+ // included). Guard the -1 -> the body is everything after the closing ---.
217
+ const after = norm.indexOf(NL, end + 1);
218
+ if (after === -1) {
219
+ // No newline after the closing delimiter: the body is empty (or just the
220
+ // rest of that final line, which IS the `---`).
221
+ const rest = norm.slice(end + 1);
222
+ return rest.replace(/^-*$/, '').trim();
223
+ }
224
+ return norm.slice(after + 1).trim();
215
225
  }
216
226
  async function findSkillFiles(root, depth = 3) {
217
227
  const out = [];
@@ -500,11 +510,17 @@ async function loadScopedAgents(workdir, touchPaths) {
500
510
  const out = [];
501
511
  const seen = new Set();
502
512
  for (const dir of dirs) {
513
+ // AGENTS.md is head-clipped; MEMORY.md is append-only so it uses the
514
+ // memory budget and keeps its TAIL (same rule as the main memory chain) —
515
+ // otherwise the newest notes in a large nested MEMORY.md were lost.
503
516
  for (const names of [AGENT_NAMES, MEMORY_NAMES]) {
517
+ const isMemory = names === MEMORY_NAMES;
504
518
  const p = await findNamedFile(dir, names);
505
519
  if (!p || seen.has(p))
506
520
  continue;
507
- const f = await readIfFile(p, MAX_FILE_BASE);
521
+ const f = isMemory
522
+ ? await readIfFile(p, MAX_MEMORY_FILE, true)
523
+ : await readIfFile(p, MAX_FILE_BASE);
508
524
  if (!f)
509
525
  continue;
510
526
  seen.add(p);
package/dist/gitTools.js CHANGED
@@ -150,7 +150,11 @@ export function createGitTools(workdir) {
150
150
  const err = await ensureRepo();
151
151
  if (err)
152
152
  return err;
153
- return runGitArgs(['log', '--oneline', '--decorate', '-n', String(Number(count) || 10)], workdir, 10_000);
153
+ // Guard the count: `Number(count)||10` let -3 and Infinity through as
154
+ // literal `-n -3` / `-n Infinity`. Keep a positive integer only.
155
+ const n = Math.trunc(Number(count));
156
+ const lim = Number.isFinite(n) && n > 0 ? n : 10;
157
+ return runGitArgs(['log', '--oneline', '--decorate', '-n', String(lim)], workdir, 10_000);
154
158
  },
155
159
  },
156
160
  {
@@ -238,14 +242,17 @@ export function createGitTools(workdir) {
238
242
  const fs = await import('fs/promises');
239
243
  const path = await import('path');
240
244
  const os = await import('os');
241
- const tmp = path.join(os.tmpdir(), `dsa-commit-${Date.now()}.txt`);
242
- await fs.writeFile(tmp, msg, 'utf-8');
245
+ // O_EXCL ('wx') so a pre-created symlink at this predictable path
246
+ // cannot redirect the write to another file (TOCTOU on a shared /tmp).
247
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zames-commit-'));
248
+ const tmp = path.join(tmpDir, 'msg.txt');
249
+ await fs.writeFile(tmp, msg, { encoding: 'utf-8', flag: 'wx' });
243
250
  try {
244
251
  const result = await runGitArgs(['commit', '-F', tmp], workdir, 30_000);
245
252
  return `add: ${addResult}\ncommit: ${result}`;
246
253
  }
247
254
  finally {
248
- await fs.unlink(tmp).catch(() => { });
255
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
249
256
  }
250
257
  },
251
258
  },
@@ -269,6 +276,15 @@ export function createGitTools(workdir) {
269
276
  if (!/^[A-Za-z0-9._/-]+$/.test(b) || b.startsWith('-')) {
270
277
  return `Error: invalid branch name: ${b}`;
271
278
  }
279
+ // The regex above allows `..` and `@{`, which git parses as a REFSPEC
280
+ // RANGE (`git push origin a..b`), not a branch. Ask git itself so the
281
+ // name is valid by construction.
282
+ const refCheck = await runGitArgs(['check-ref-format', '--branch', b], workdir, 5000);
283
+ // On success git echoes the branch name; on failure it prints an
284
+ // `Exit code: <n>` / `fatal:` block instead.
285
+ if (refCheck.trim() !== b) {
286
+ return `Error: invalid branch name: ${b}`;
287
+ }
272
288
  const args = ['push'];
273
289
  if (setUpstream)
274
290
  args.push('-u');
package/dist/index.js CHANGED
@@ -85,7 +85,13 @@ const headless = hasFlag('--headed')
85
85
  : hasFlag('--headless') || config.headless;
86
86
  const debug = hasFlag('--debug') || config.debug;
87
87
  const maxIterArg = getArg('--max-iter', null);
88
- const maxIter = maxIterArg !== null ? Number(maxIterArg) : config.maxIterations;
88
+ // A non-numeric `--max-iter abc` yields NaN, which the loop treats as
89
+ // "unlimited" (NaN > 0 is false). Fall back to the configured value instead
90
+ // of silently disabling the cap.
91
+ const maxIterParsed = maxIterArg !== null ? Number(maxIterArg) : NaN;
92
+ const maxIter = Number.isFinite(maxIterParsed)
93
+ ? maxIterParsed
94
+ : config.maxIterations;
89
95
  const positional = getPositional();
90
96
  const task = getArg('--task', positional.join(' ').trim() || null);
91
97
  const chatIdArg = getArg('--chat', null);
package/dist/input.js CHANGED
@@ -892,28 +892,17 @@ export class LineEditor {
892
892
  }
893
893
  const cols = process.stdout.columns || 80;
894
894
  const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
895
- const sugg = this._suggestions();
896
- const shown = sugg.slice(0, 8);
897
- this._suggestCount = shown.length;
898
- const linesBelow = shown.length
899
- ? shown.length + (sugg.length > shown.length ? 1 : 0)
900
- : 0;
895
+ // Reuse the SAME windowed+highlighted suggestion lines as _writeBlock.
896
+ // Before, this used a hardcoded `sugg.slice(0, 8)`, so navigating past
897
+ // page 1 (Ctrl+N/P) left the repaint showing the first page without the
898
+ // highlight, and linesBelow drifted from the real row count.
899
+ const sug = this._suggestionLines(this._suggestOffset);
900
+ const linesBelow = sug.rows;
901
901
  // Move up to the INPUT top using the OLD cursor row (on screen right now).
902
902
  this._eraseInputOnly(this.cursorRowFromTop - this._statusTop);
903
903
  let out = lay.rows.map((r) => r.prefix + r.text).join(NL);
904
- if (shown.length) {
905
- const maxName = Math.max(...shown.map((c) => c.name.length));
906
- const lines = shown.map((c) => {
907
- const name = theme.prompt(c.name.padEnd(maxName));
908
- const desc = theme.dim(' ' + c.description);
909
- return ' ' + name + desc;
910
- });
911
- out += NL + lines.join(NL);
912
- const hidden = sugg.length - shown.length;
913
- if (hidden > 0)
914
- out +=
915
- NL +
916
- theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
904
+ if (sug.lines.length) {
905
+ out += NL + sug.lines.join(NL);
917
906
  }
918
907
  // A buffer change can UNWRAP the input (backspace on a wrapped line), so
919
908
  // the block can get shorter here too — push it back to the bottom BEFORE
package/dist/mcp.js CHANGED
@@ -44,9 +44,9 @@ export function hardenPlaywrightArgs(command, args) {
44
44
  const hay = (String(command || '') + ' ' + args.join(' ')).toLowerCase();
45
45
  if (!hay.includes('playwright') || !hay.includes('mcp'))
46
46
  return args;
47
- if (args.some((a) => a === '--isolated' ||
47
+ if (args.some((a) => a.startsWith('--isolated') ||
48
48
  a.startsWith('--user-data-dir') ||
49
- a === '--extension' ||
49
+ a.startsWith('--extension') ||
50
50
  a.startsWith('--cdp-endpoint'))) {
51
51
  return args;
52
52
  }
@@ -292,10 +292,15 @@ export function dedupeToolNames(tools) {
292
292
  }
293
293
  // Connect to ONE server and return a ready connection (or an error).
294
294
  async function connectServer(name, cfg, debug) {
295
+ // Declared OUTSIDE the try so the catch can close a half-open server: a
296
+ // stdio transport SPAWNS a child process on connect, and if connect() or
297
+ // listTools() throws we would otherwise leak an orphan process on every run.
298
+ let client = null;
299
+ let transport = null;
295
300
  try {
296
301
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
297
- const client = new Client({ name: 'zames', version: packageVersion() }, { capabilities: {} });
298
- let transport;
302
+ const c = new Client({ name: 'zames', version: packageVersion() }, { capabilities: {} });
303
+ client = c;
299
304
  if (cfg.url) {
300
305
  if (cfg.transport === 'sse') {
301
306
  const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
@@ -320,8 +325,8 @@ async function connectServer(name, cfg, debug) {
320
325
  stderr: 'pipe',
321
326
  });
322
327
  }
323
- await client.connect(transport);
324
- const listed = await client.listTools();
328
+ await c.connect(transport);
329
+ const listed = await c.listTools();
325
330
  const tools = (listed.tools || []).map((t) => {
326
331
  const tool = t;
327
332
  return {
@@ -331,11 +336,22 @@ async function connectServer(name, cfg, debug) {
331
336
  };
332
337
  });
333
338
  debug?.(`MCP: connected to ${name} (${tools.length} tools)`);
334
- return { name, client, tools };
339
+ return { name, client: c, tools };
335
340
  }
336
341
  catch (e) {
337
342
  const msg = e.message || String(e);
338
343
  debug?.(`MCP: failed to connect to ${name}: ${msg}`);
344
+ // Close a half-open connection so a spawned stdio child is not orphaned.
345
+ try {
346
+ if (client)
347
+ await client.close().catch(() => { });
348
+ }
349
+ catch { }
350
+ try {
351
+ if (transport?.close)
352
+ await Promise.resolve(transport.close()).catch(() => { });
353
+ }
354
+ catch { }
339
355
  return { error: msg };
340
356
  }
341
357
  }
package/dist/sessions.js CHANGED
@@ -98,11 +98,16 @@ function writeIndex({ id, workdir }) {
98
98
  if (!index.byWorkdir || typeof index.byWorkdir !== 'object') {
99
99
  index.byWorkdir = {};
100
100
  }
101
+ // An EMPTY workdir must not become a byWorkdir key: it would make every
102
+ // project fall back to `index.last` and restore a chat from ANOTHER
103
+ // project on the next launch. Only track a real directory.
101
104
  const key = workdir || '';
102
- index.byWorkdir[key] = id;
103
- index.last = id;
104
- index.updatedAt = new Date().toISOString();
105
- writeJsonAtomic(INDEX_FILE, index);
105
+ if (key) {
106
+ index.byWorkdir[key] = id;
107
+ index.last = id;
108
+ index.updatedAt = new Date().toISOString();
109
+ writeJsonAtomic(INDEX_FILE, index);
110
+ }
106
111
  }
107
112
  catch { }
108
113
  }
package/dist/shell.js CHANGED
@@ -23,6 +23,15 @@ export function assertCommandInsideRoot(root, command) {
23
23
  const raw = m[1].trim().replace(/^["']|["']$/g, '');
24
24
  if (!raw || raw === '-')
25
25
  continue;
26
+ // A `cd` target we cannot RESOLVE statically is rejected: the shell would
27
+ // expand it AFTER this scan, so `d=/; cd $d` would otherwise slip past as
28
+ // root/$d. `$HOME`/`${HOME}` is the one variable we CAN expand (below), so
29
+ // it is allowed; any OTHER `$var`, a backtick substitution or `~user` is
30
+ // refused outright.
31
+ const withoutHome = raw.replace(/\$\{?HOME\}?/g, '');
32
+ if (/[$`]/.test(withoutHome) || /^~[^/\\]/.test(raw)) {
33
+ throw new Error('Sandbox: cannot verify cd target ' + raw + ' (variable/substitution)');
34
+ }
26
35
  // sh expands ~ and $HOME AFTER this check, so expand them here as well —
27
36
  // otherwise `cd ~` passes the guard and then leaves the root.
28
37
  const expanded = raw
@@ -64,10 +64,10 @@ export class Transcript {
64
64
  if (!stream)
65
65
  return Promise.resolve();
66
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.
67
+ // Attach the error handler BEFORE end(): an error emitted synchronously
68
+ // by end() would otherwise reach no listener on this tick.
70
69
  stream.on('error', () => resolve());
70
+ stream.end(() => resolve());
71
71
  });
72
72
  }
73
73
  }
package/dist/undo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { randomBytes } from 'crypto';
4
5
  import { writeJsonAtomic } from './fsutil.js';
5
6
  const UNDO_DIR = path.join(os.homedir(), '.zames', 'undo');
6
7
  const INDEX = path.join(UNDO_DIR, 'index.json');
@@ -44,7 +45,12 @@ export class UndoStore {
44
45
  const hash = Buffer.from(filePath).toString('hex').slice(0, 16);
45
46
  let backupFile = null;
46
47
  if (existed) {
47
- backupFile = path.join(UNDO_DIR, `${stamp}-${hash}.bak`);
48
+ // The stamp is only ms-precision and the hash is the first 8 bytes of
49
+ // the path, so two SIBLING files edited in the same millisecond would
50
+ // collide and the second write would clobber the first (losing the
51
+ // backup). Add a random suffix so a backup name is unique.
52
+ const uniq = randomBytes(4).toString('hex');
53
+ backupFile = path.join(UNDO_DIR, `${stamp}-${hash}-${uniq}.bak`);
48
54
  await fs.writeFile(backupFile, content);
49
55
  }
50
56
  const history = await this._readIndex();
package/dist/web.js CHANGED
@@ -71,12 +71,34 @@ export function isPrivateIp(ip) {
71
71
  const low = ip.toLowerCase();
72
72
  if (low === '::1' || low === '::')
73
73
  return true;
74
- if (low.startsWith('fe80') || low.startsWith('fc') || low.startsWith('fd')) {
74
+ // IPv6 link-local is fe80::/10, i.e. fe80..febf (the old `startsWith('fe80')`
75
+ // missed fe90::, fea0:: and febf::). ULA is fc00::/7 (fc..fd). fec0::/10
76
+ // (deprecated site-local) is also treated as non-public.
77
+ if (/^fe[89abcde]/.test(low) ||
78
+ low.startsWith('fc') ||
79
+ low.startsWith('fd')) {
75
80
  return true;
76
81
  }
82
+ // IPv4-mapped in DOTTED form (::ffff:127.0.0.1).
77
83
  const mapped = low.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
78
84
  if (mapped)
79
85
  return isPrivateIp(mapped[1]);
86
+ // IPv4-mapped in HEX form (::ffff:7f00:1 == 127.0.0.1) and the NAT64
87
+ // well-known prefix 64:ff9b::/96 (which can encode 127.0.0.1 too).
88
+ const mappedHex = low.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
89
+ if (mappedHex) {
90
+ const hi = parseInt(mappedHex[1], 16);
91
+ const lo = parseInt(mappedHex[2], 16);
92
+ return isPrivateIp(((hi >> 8) & 0xff) +
93
+ '.' +
94
+ (hi & 0xff) +
95
+ '.' +
96
+ ((lo >> 8) & 0xff) +
97
+ '.' +
98
+ (lo & 0xff));
99
+ }
100
+ if (low.startsWith('64:ff9b:'))
101
+ return true;
80
102
  return false;
81
103
  }
82
104
  export function isPrivateHostname(host) {
@@ -87,6 +109,13 @@ export function isPrivateHostname(host) {
87
109
  return true;
88
110
  if (h === 'metadata.google.internal')
89
111
  return true;
112
+ // Internal/mDNS/service names that never belong to the public internet.
113
+ if (h.endsWith('.internal') || h === 'internal')
114
+ return true;
115
+ if (h.endsWith('.local') || h === 'local')
116
+ return true;
117
+ if (h.endsWith('.home.arpa'))
118
+ return true;
90
119
  return false;
91
120
  }
92
121
  export async function assertPublicUrl(rawUrl) {
@@ -191,6 +220,10 @@ export async function httpFetch(url, { timeout = DEFAULT_TIMEOUT, headers = {},
191
220
  const next = new URL(loc, current).toString();
192
221
  // Validate the TARGET before requesting it.
193
222
  await assertPublicUrl(next);
223
+ // Drain/close the redirect body: otherwise each hop leaks a socket
224
+ // (up to MAX_REDIRECTS per fetch), which exhausts the pool on a long
225
+ // series of fetches.
226
+ await res.body?.cancel().catch(() => { });
194
227
  current = next;
195
228
  continue;
196
229
  }
@@ -310,7 +343,7 @@ export function createWebTools() {
310
343
  if (!/^https?:\/\//i.test(String(url))) {
311
344
  return `Error: URL must start with http:// or https://`;
312
345
  }
313
- const limit = Math.min(Number(maxChars) || MAX_TEXT, 60_000);
346
+ const limit = Math.max(1, Math.min(Number(maxChars) || MAX_TEXT, 60_000));
314
347
  try {
315
348
  await assertPublicUrl(String(url));
316
349
  if (render) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.73.2",
3
+ "version": "2.73.3",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {