zames_pro 2.78.0 → 2.78.2

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,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.78.2] - 2026-10-18
11
+
12
+ ### Fixed
13
+
14
+ - **goal:** clampGoalCondition does not cut a surrogate pair
15
+ - **web:** judge the expanded IPv6 form in the SSRF guard
16
+ - **permissions:** match a command rule against an avv array
17
+ - **scheduler:** OR a restricted day-of-month and day-of-week
18
+ - **qwen:** guest-limit classifier ignores a normal answer
19
+ - **net-capture:** read a top-level accumulated_token_usage pair
20
+ - **tools:** Read does not clip a surrogate pair in half
21
+ - **tool-summary:** do not cut a surrogate pair in the log line
22
+ - **shell:** do not start a background trim on a lone surrogate
23
+ - **sandbox:** match eval at a command position, not as a substring
24
+ - **goal:** parse the spaced and hyphenated NOT MET verdict
25
+ - **hooks:** cap the combined PostToolUse and lifecycle output
26
+ - **fsutil:** write atomic files through a symlink, not over it
27
+ - **mcp:** sanitize the server half of a qualified tool name
28
+ - **statusline:** settle on our own stdout end, not on the pipe closing
29
+ - **provider:** match the handoff workdir by real path, not by string
30
+ - **checkpoint:** retention deletes same-millisecond sibling archives
31
+ - **undo:** a missing backup file no longer wedges the undo history
32
+ - **sessions:** a stale internal id no longer hides the saved session
33
+ - **attach:** keep @file references inside the sandbox root
34
+
35
+ ## [2.78.1] - 2026-10-18
36
+
37
+ ### Fixed
38
+
39
+ - **permissions:** match a command rule against every plausible command key
40
+ - **hooks:** cap a PreToolUse denial so it cannot flood the tool result
41
+ - **diagnostics:** treat a zero-error watch banner as clean
42
+ - **statusline:** cap the hook output so a chatty command cannot flood the row
43
+
10
44
  ## [2.78.0] - 2026-10-18
11
45
 
12
46
  ### Added
@@ -9,6 +9,15 @@ const NL = String.fromCharCode(10);
9
9
  // Resolve a pasted string into a path to an EXISTING file. Handles quoted
10
10
  // paths (drag&drop from some file managers adds quotes) and paths relative to
11
11
  // the working directory. Returns null when nothing matches.
12
+ //
13
+ // Every candidate is checked against the sandbox root (the REAL path, so
14
+ // a symlink escape is refused too). The `..`/cwd candidates exist to help an
15
+ // operator paste a path that LOOKS relative to the project, not to grant
16
+ // access above it: without this guard a reference with a `..` inlined a file
17
+ // outside the sandbox, while every other file tool rejects that path via
18
+ // safePath(). The search continues past an escaping candidate (a regular
19
+ // file inside the project must still resolve), but no candidate outside is
20
+ // ever returned.
12
21
  export async function resolveAttachPath(workdir, raw) {
13
22
  let s = String(raw || '').trim();
14
23
  if (!s)
@@ -34,11 +43,25 @@ export async function resolveAttachPath(workdir, raw) {
34
43
  ];
35
44
  for (const c of candidates) {
36
45
  const st = await fs.stat(c).catch(() => null);
37
- if (st && st.isFile())
46
+ if (!st || !st.isFile())
47
+ continue;
48
+ if (await isInside(workdir, c))
38
49
  return c;
39
50
  }
40
51
  return null;
41
52
  }
53
+ // True when the REAL path of `target` stays under the REAL workdir root.
54
+ // realpath (not the lexical path) is required here for the same reason as
55
+ // safePath() in src/sandbox.ts: a symlink inside the project can point
56
+ // outside it.
57
+ async function isInside(workdir, target) {
58
+ const root = await fs.realpath(path.resolve(workdir)).catch(() => null);
59
+ const real = await fs.realpath(target).catch(() => null);
60
+ if (!root || !real)
61
+ return false;
62
+ const rel = path.relative(root, real);
63
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
64
+ }
42
65
  // Per-file and total caps for `@path` inlining (B5). A reference should SAVE
43
66
  // round-trips, not flood the context: a huge file is truncated with a note
44
67
  // telling the agent to read the rest itself.
@@ -176,10 +176,14 @@ export class CheckpointStore {
176
176
  const history = await this._readIndex();
177
177
  history.push(record);
178
178
  const kept = rotateRecords(history, this.maxBackups);
179
- // Drop the archives that fell out of the retention window.
180
- const keptStamps = new Set(kept.map((r) => r.stamp));
179
+ // Drop the archives that fell out of the retention window. Keyed by the
180
+ // RECORD, not by its stamp: the stamp is ms-precision, so two snapshots of
181
+ // one workdir taken in the same millisecond share it — the pruned record
182
+ // then looked "kept" thanks to its sibling and its archive stayed on disk
183
+ // as an orphan forever.
184
+ const keptSet = new Set(kept);
181
185
  for (const r of history) {
182
- if (!keptStamps.has(r.stamp))
186
+ if (!keptSet.has(r))
183
187
  await fs.rm(r.file, { force: true }).catch(() => { });
184
188
  }
185
189
  writeJsonAtomic(this.indexFile, kept);
@@ -23,11 +23,17 @@ export function summarizeDiagnostics(raw, maxChars) {
23
23
  const text = String(raw || '').trim();
24
24
  if (!text)
25
25
  return null;
26
- // tsc prints nothing on success; eslint prints nothing too. Guard against a
27
- // benign banner that some tools emit.
28
- if (/^(no errors?\.?|0 problems?.*)$/im.test(text) && text.length < 40) {
26
+ // tsc prints nothing on success, but with `--watch` it prints a banner
27
+ // ("Found 0 errors. Watching for file changes.") that is longer than the
28
+ // old 40-char cutoff — so every edit in watch mode appended a pointless
29
+ // diagnostics block to the tool result. Treat a ZERO-count banner as clean
30
+ // however long it is; a non-zero count still falls through to the lines.
31
+ if (/^(?:found\s+)?0\s+errors?\b/im.test(text))
32
+ return null;
33
+ if (/^no errors?\.?$/im.test(text))
34
+ return null;
35
+ if (/^0 problems?\b/im.test(text))
29
36
  return null;
30
- }
31
37
  // Keep only error-looking lines when we can recognise them, so a long build
32
38
  // log collapses to the actual problems. Fall back to the whole output.
33
39
  const lines = text.split(/\r?\n/);
package/dist/fsutil.js CHANGED
@@ -16,7 +16,18 @@ import path from 'path';
16
16
  // multi-user machine. We also chmod the target after rename because a
17
17
  // pre-existing file keeps its old mode (rename does not change the inode).
18
18
  export function writeFileAtomic(file, data) {
19
- const tmp = file + '.tmp-' + process.pid;
19
+ // Follow a SYMLINK first: a dotfiles setup links ~/.zames/config.json into a
20
+ // git repo, and a project may link .zamesrc.json. Renaming a temp file over
21
+ // the link replaces the LINK with a regular file — the real target keeps the
22
+ // stale content and the next launch reads what the operator never edited.
23
+ let target = file;
24
+ try {
25
+ target = fs.realpathSync(file);
26
+ }
27
+ catch {
28
+ // Missing (first write) or broken link: write to the path itself.
29
+ }
30
+ const tmp = target + '.tmp-' + process.pid;
20
31
  // fsync the file BEFORE rename: without it a power loss can leave the
21
32
  // renamed name pointing at an empty/partial inode (rename itself is atomic,
22
33
  // but the data blocks may not have reached the disk yet).
@@ -29,7 +40,7 @@ export function writeFileAtomic(file, data) {
29
40
  finally {
30
41
  fs.closeSync(fd);
31
42
  }
32
- fs.renameSync(tmp, file);
43
+ fs.renameSync(tmp, target);
33
44
  }
34
45
  catch (e) {
35
46
  // A failed write/fsync/rename (full disk, EIO, a directory in the way)
package/dist/goal.js CHANGED
@@ -13,7 +13,14 @@ export function clampGoalCondition(condition) {
13
13
  const g = String(condition ?? '').trim();
14
14
  if (g.length <= GOAL_CONDITION_CAP)
15
15
  return g;
16
- return g.slice(0, GOAL_CONDITION_CAP);
16
+ // Never cut a surrogate PAIR in half: the condition is injected into the
17
+ // evaluator prompt, and a lone high surrogate is a broken code point (same
18
+ // rule as truncateToolResult / capReport / clipLine / oneLine).
19
+ let cut = GOAL_CONDITION_CAP;
20
+ const last = g.charCodeAt(cut - 1);
21
+ if (last >= 0xd800 && last <= 0xdbff)
22
+ cut--;
23
+ return g.slice(0, cut);
17
24
  }
18
25
  // The evaluator prompt is AGENT-facing (English), like the system prompt and
19
26
  // the compact summary prompt. It runs in the SAME chat as the work so the
@@ -74,7 +81,12 @@ export function parseGoalVerdict(text) {
74
81
  // spaces (`**VERDICT:** MET`) — a markdown-model answer did exactly that and
75
82
  // the strict `VERDICT:` matcher returned null, so the goal never parsed and
76
83
  // the loop auto-continued until the turn budget.
77
- const m = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(MET|NOT_MET|IMPOSSIBLE)/i.exec(t);
84
+ // The value itself may be written the human way (`NOT MET`, `NOT-MET`): a
85
+ // strict `NOT_MET` matcher returned null for those, and null means "continue"
86
+ // — the goal loop then auto-continued until the turn budget although the
87
+ // judge had already said NOT_MET. `MET` is tried LAST so it cannot match the
88
+ // tail of `NOT MET`.
89
+ const m = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(IMPOSSIBLE|NOT[_ -]?MET|MET)/i.exec(t);
78
90
  if (!m)
79
91
  return null;
80
92
  const raw = m[1].toUpperCase();
package/dist/hooks.js CHANGED
@@ -12,6 +12,12 @@ const LIFECYCLE_EVENTS = [
12
12
  // tool call must not be held hostage by it.
13
13
  const HOOK_TIMEOUT_MS = 10_000;
14
14
  const HOOK_MAX_BUFFER = 1024 * 1024;
15
+ // A PreToolUse denial becomes the tool result the model reads, so it is capped
16
+ // like any other tool output.
17
+ const HOOK_DENIAL_CAP = 4000;
18
+ // Combined stdout of a PostToolUse / lifecycle hook. It lands in a tool result
19
+ // or in the chat, so it is a notice, not a log dump.
20
+ const HOOK_OUTPUT_CAP = 8000;
15
21
  export function hooksConfigPath(workdir) {
16
22
  return path.join(workdir, '.zames', 'hooks.json');
17
23
  }
@@ -142,7 +148,15 @@ export async function runPreToolUse(hooks, tool, args, workdir) {
142
148
  const r = await runHook(entry, 'PreToolUse', tool, args, workdir);
143
149
  if (r.code !== 0) {
144
150
  const reason = (r.stderr || r.stdout).trim();
145
- return reason || `hook exited with code ${r.code}`;
151
+ // The denial text becomes a TOOL RESULT the model reads: a hook that
152
+ // dumps a log must not flood the context with one call.
153
+ const capped = reason.slice(0, HOOK_DENIAL_CAP);
154
+ const suffix = reason.length > HOOK_DENIAL_CAP
155
+ ? '\n… [denial truncated: ' +
156
+ (reason.length - HOOK_DENIAL_CAP) +
157
+ ' chars omitted]'
158
+ : '';
159
+ return capped + suffix || `hook exited with code ${r.code}`;
146
160
  }
147
161
  }
148
162
  return null;
@@ -170,13 +184,28 @@ export async function runLifecycleHooks(hooks, event, workdir, extra = {}) {
170
184
  async function runEventHooks(hooks, event, workdir, tool, args, result) {
171
185
  const list = hooks?.[event];
172
186
  const parts = [];
187
+ let total = 0;
173
188
  for (const entry of list ?? []) {
174
189
  if (!matchesHook(entry, tool))
175
190
  continue;
176
191
  const r = await runHook(entry, event, tool, args, workdir, result);
177
192
  const out = r.stdout.trim();
178
- if (out)
179
- parts.push(out);
193
+ if (!out)
194
+ continue;
195
+ // The combined output goes into a tool result (PostToolUse) or straight
196
+ // into the chat (SessionStart and the other lifecycle events), so it must
197
+ // stay a notice: a hook running `npm test` or dumping a log used to append
198
+ // hundreds of KB and flood the context.
199
+ if (total >= HOOK_OUTPUT_CAP)
200
+ break;
201
+ const piece = out.slice(0, HOOK_OUTPUT_CAP - total);
202
+ total += piece.length;
203
+ parts.push(piece);
180
204
  }
181
- return parts.join(String.fromCharCode(10));
205
+ const joined = parts.join(String.fromCharCode(10));
206
+ if (joined.length <= HOOK_OUTPUT_CAP)
207
+ return joined;
208
+ return (joined.slice(0, HOOK_OUTPUT_CAP) +
209
+ String.fromCharCode(10) +
210
+ '… [hook output truncated]');
182
211
  }
package/dist/mcp.js CHANGED
@@ -275,7 +275,13 @@ function safeJson(v) {
275
275
  // servers cannot collide. The prefix uses __ (double underscore) because
276
276
  // the model must be able to reproduce it exactly.
277
277
  export function qualifyToolName(server, tool) {
278
- return server + '__' + tool.replace(/[^A-Za-z0-9_]/g, '_');
278
+ // BOTH halves must be sanitized: a server name with a space ('my server')
279
+ // produced 'my server__tool', which the model cannot reproduce in a JSON
280
+ // tool call. The same rule on both sides also makes two spellings of one
281
+ // server collide on ONE qualified name, so dedupeToolNames can rename the
282
+ // duplicate instead of leaving both tools unreachable.
283
+ const clean = (s) => String(s || '').replace(/[^A-Za-z0-9_]/g, '_');
284
+ return clean(server) + '__' + clean(tool);
279
285
  }
280
286
  // Make every tool name unique IN PLACE. agent-loop resolves a call by name and
281
287
  // takes the first match, so a duplicate silently shadows the later tool. The
@@ -270,6 +270,12 @@ export function extractTokenUsage(body) {
270
270
  : undefined;
271
271
  if (resp)
272
272
  consider(resp.accumulated_token_usage);
273
+ // A BATCH update can also arrive with the pair at the TOP level:
274
+ // {"p":"accumulated_token_usage","v":N} (the same pair DeepSeek nests
275
+ // inside v: [...]). Reading only the nested form made the context counter
276
+ // silently stale for that stream shape.
277
+ if (o.p === 'accumulated_token_usage')
278
+ consider(o.v);
273
279
  // A BATCH update: v is an array of {"p":"accumulated_token_usage","v":N}.
274
280
  if (Array.isArray(o.v)) {
275
281
  for (const item of o.v) {
@@ -63,6 +63,10 @@ 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
+ // The command may be named differently by an MCP tool (its JSON Schema picks
67
+ // the field name) or invented by the model. Checking every plausible key keeps
68
+ // a `command` rule from being bypassed just because the field is called `cmd`.
69
+ const COMMAND_KEYS = ['command', 'cmd', 'shell_command', 'script', 'argv'];
66
70
  // Best-effort check whether a string looks like a filesystem path. Used for
67
71
  // tools whose arguments are not covered by PATH_KEYS (MCP tools take arbitrary
68
72
  // arg names from their JSON Schema), so a `path` rule is not silently
@@ -106,7 +110,18 @@ export function decidePermission(policy, tool, args) {
106
110
  if (!policy)
107
111
  return { action: 'allow', reason: 'no policy' };
108
112
  const a = (args ?? {});
109
- const command = typeof a['command'] === 'string' ? a['command'] : '';
113
+ // A command may be a STRING (`command`, `cmd`, ...) or an ARRAY (`argv`,
114
+ // the natural shape for execFile-style MCP tools). Accepting only strings
115
+ // let a `command` deny be bypassed by passing the words as an array.
116
+ const commands = [];
117
+ for (const k of COMMAND_KEYS) {
118
+ const v = a[k];
119
+ if (typeof v === 'string')
120
+ commands.push(v);
121
+ else if (Array.isArray(v)) {
122
+ commands.push(v.filter((x) => typeof x === 'string').join(' '));
123
+ }
124
+ }
110
125
  const paths = collectPathCandidates(tool, args);
111
126
  for (const r of policy.rules) {
112
127
  if (r.tool) {
@@ -116,7 +131,7 @@ export function decidePermission(policy, tool, args) {
116
131
  }
117
132
  if (r.command) {
118
133
  const re = safeRegex(r.command);
119
- if (!re || !command || !re.test(command))
134
+ if (!re || !commands.some((c) => re.test(c)))
120
135
  continue;
121
136
  }
122
137
  if (r.path) {
@@ -52,7 +52,7 @@ export function readProviderHandoff(provider, workdir) {
52
52
  }
53
53
  if (raw.provider !== provider)
54
54
  return null;
55
- if ((raw.workdir || '') !== (workdir || ''))
55
+ if (!sameDir(raw.workdir, workdir))
56
56
  return null;
57
57
  return raw;
58
58
  }
@@ -60,6 +60,30 @@ export function readProviderHandoff(provider, workdir) {
60
60
  return null;
61
61
  }
62
62
  }
63
+ // Two paths that denote the SAME directory must match: the handoff is consumed
64
+ // by the NEXT launch in that directory, and the path there is not guaranteed to
65
+ // be byte-identical — a trailing separator (a `/cd sub/` leaves one) or a
66
+ // symlinked route to the project used to make a lexical `!==` silently drop the
67
+ // carried-over summary. realpath is the comparison; a missing dir falls back to
68
+ // the resolved lexical path so a deleted project still matches its record.
69
+ function sameDir(a, b) {
70
+ const x = String(a ?? '');
71
+ const y = String(b ?? '');
72
+ if (x === y)
73
+ return true;
74
+ if (!x || !y)
75
+ return false;
76
+ const real = (p) => {
77
+ const abs = path.resolve(p);
78
+ try {
79
+ return fs.realpathSync(abs);
80
+ }
81
+ catch {
82
+ return abs;
83
+ }
84
+ };
85
+ return real(x) === real(y);
86
+ }
63
87
  /** Remove the handoff once it has been posted (or is stale). Best-effort. */
64
88
  export function clearProviderHandoff() {
65
89
  try {
@@ -256,6 +256,14 @@ export function isGuestLimitText(body) {
256
256
  const b = String(body || '');
257
257
  if (!b)
258
258
  return false;
259
+ // Same rule as isRateLimitText / isServerBusyText: a normal ANSWER may
260
+ // MENTION the guest limit (the model explaining Qwen's guest mode, a pasted
261
+ // notice). Only an error payload or a body with NO answer content can be the
262
+ // real notice; otherwise ask() would fail fast with "log in" and throw the
263
+ // answer away.
264
+ const errNotice = /event:\s*error|"type"\s*:\s*"error"|"error"\s*:/i.test(b);
265
+ if (!errNotice && hasAnswerContent(b))
266
+ return false;
259
267
  return /guest chat limit|guest.*limit|log in to continue|гостевой лимит/i.test(b);
260
268
  }
261
269
  export function isServerBusyText(body) {
package/dist/scheduler.js CHANGED
@@ -41,6 +41,17 @@ export function formatInterval(ms) {
41
41
  return ms / 1000 + 's';
42
42
  return ms + 'ms';
43
43
  }
44
+ // The DAY rule of standard cron: when BOTH day-of-month and day-of-week are
45
+ // restricted they are ORed (`0 9 1 * 1` = the 1st OR any Monday); otherwise
46
+ // they are plain ANDed (an unrestricted field matches every day). ANDing a
47
+ // restricted pair made the job fire only when the two coincided.
48
+ function dayMatches(c, date) {
49
+ const domOk = c.dom.has(date.getDate());
50
+ const dowOk = c.dow.has(date.getDay());
51
+ if (c.domRestricted && c.dowRestricted)
52
+ return domOk || dowOk;
53
+ return domOk && dowOk;
54
+ }
44
55
  // Parse one cron field ("*", "a-b", "*/n", "a,b", "a-b/n") into a value set.
45
56
  function parseCronField(field, min, max) {
46
57
  const out = new Set();
@@ -95,19 +106,26 @@ export function parseCron(expr) {
95
106
  const dow = new Set();
96
107
  for (const d of dowRaw)
97
108
  dow.add(d === 7 ? 0 : d);
98
- return { minute, hour, dom, month, dow };
109
+ return {
110
+ minute,
111
+ hour,
112
+ dom,
113
+ month,
114
+ dow,
115
+ domRestricted: parts[2].trim() !== '*',
116
+ dowRestricted: parts[4].trim() !== '*',
117
+ };
99
118
  }
100
- // Does the given Date match the cron expression? dom and dow are ANDed (the
101
- // common, predictable behavior); to schedule "every day" use `*` for one.
119
+ // Does the given Date match the cron expression? The day rule is standard
120
+ // cron: dom/dow are ORed when BOTH are restricted, ANDed otherwise.
102
121
  export function cronMatches(expr, date) {
103
122
  const c = parseCron(expr);
104
123
  if (!c)
105
124
  return false;
106
125
  return (c.minute.has(date.getMinutes()) &&
107
126
  c.hour.has(date.getHours()) &&
108
- c.dom.has(date.getDate()) &&
109
127
  c.month.has(date.getMonth() + 1) &&
110
- c.dow.has(date.getDay()));
128
+ dayMatches(c, date));
111
129
  }
112
130
  // Next epoch ms at which the expression fires, strictly after `fromMs`. Scans
113
131
  // minute by minute (bounded to ~4 years); a one-time cost when a job is
@@ -125,9 +143,8 @@ export function nextCronTime(expr, fromMs) {
125
143
  while (d.getTime() <= limit) {
126
144
  if (c.minute.has(d.getMinutes()) &&
127
145
  c.hour.has(d.getHours()) &&
128
- c.dom.has(d.getDate()) &&
129
146
  c.month.has(d.getMonth() + 1) &&
130
- c.dow.has(d.getDay())) {
147
+ dayMatches(c, d)) {
131
148
  return d.getTime();
132
149
  }
133
150
  d.setMinutes(d.getMinutes() + 1);
package/dist/sessions.js CHANGED
@@ -135,13 +135,17 @@ export function loadLastSession(workdir = '', provider) {
135
135
  try {
136
136
  const index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf-8'));
137
137
  const byWorkdir = index && index.byWorkdir ? index.byWorkdir : {};
138
- const candidates = [byWorkdir[workdir], index.last].filter((x) => Boolean(x));
138
+ // A denylisted id is useless here: its subagent chat is never saved as a
139
+ // session, so a stale entry (recorded for a chat merely SEEN) would make the
140
+ // loop run out of candidates — even though the project's real session is
141
+ // right there on disk. Filter them out instead of `continue`-ing past them,
142
+ // then fall back to the newest saved session of this workdir so a stale
143
+ // index cannot make a saved chat unresumable.
139
144
  const internal = readInternal();
145
+ const candidates = [byWorkdir[workdir], index.last].filter((x) => Boolean(x) && !internal.has(String(x)));
140
146
  for (const id of candidates) {
141
147
  // The session file may have been deleted — the index is then stale and
142
148
  // the session cannot be restored. We try the next candidate.
143
- if (internal.has(id))
144
- continue;
145
149
  const s = readSession(id);
146
150
  if (!s)
147
151
  continue;
@@ -151,7 +155,9 @@ export function loadLastSession(workdir = '', provider) {
151
155
  continue;
152
156
  return s;
153
157
  }
154
- return null;
158
+ const fallback = listSessions().find((s) => (!workdir || s.workdir === workdir) &&
159
+ (!provider || (s.provider || 'deepseek') === provider));
160
+ return fallback || null;
155
161
  }
156
162
  catch {
157
163
  return null;
package/dist/shell.js CHANGED
@@ -12,7 +12,15 @@ export function assertCommandInsideRoot(root, command) {
12
12
  // eval re-parses its argument, so a `cd` hidden inside a string is invisible
13
13
  // to the static scan; refusing eval together with cd is the honest answer
14
14
  // instead of pretending the barrier still holds.
15
- if (/\beval\b/.test(cmd) && /\bcd\b/.test(cmd)) {
15
+ //
16
+ // The test is on COMMAND POSITIONS, not on raw substrings: a bare
17
+ // `/eval/`+/cd/ matched ordinary text like `npx tsx --test
18
+ // test/goal-eval-unparsable.test.ts` (which DOES contain `cd` elsewhere, e.g.
19
+ // `cd proj && …`), so a legitimate command — even `npm test` — was rejected
20
+ // with a sandbox error. eval only re-parses when it is RUN, i.e. at the start
21
+ // of a command or after a separator, optionally via `sh -c eval …`.
22
+ const evalAtCommand = /(?:^|[;&|(]\s*|\bsh\s+-c\s+['"]?)\s*eval\b/;
23
+ if (evalAtCommand.test(cmd) && /\bcd\b/.test(cmd)) {
16
24
  throw new Error('Sandbox: eval with cd is forbidden');
17
25
  }
18
26
  // `(` and backticks open a subshell / command substitution where a `cd` is
@@ -118,10 +126,18 @@ const BG_PROCS = new Map();
118
126
  const BG_MAX_BUFFER = 1024 * 1024;
119
127
  function bgTrim(p) {
120
128
  if (p.out.length > BG_MAX_BUFFER) {
129
+ // Never START the kept window on a low surrogate: the cut slices a JS
130
+ // string, and an emoji / astral CJK char is TWO UTF-16 units, so a cut
131
+ // between them left a lone half at the head of the buffer that pollBackground
132
+ // hands to the model (same rule as truncateToolResult / capReport).
133
+ let start = p.out.length - BG_MAX_BUFFER;
134
+ const first = p.out.charCodeAt(start);
135
+ if (first >= 0xdc00 && first <= 0xdfff)
136
+ start++;
121
137
  p.out =
122
138
  '[...earlier output trimmed]' +
123
139
  String.fromCharCode(10) +
124
- p.out.slice(p.out.length - BG_MAX_BUFFER);
140
+ p.out.slice(start);
125
141
  }
126
142
  }
127
143
  /** Start a shell command in the background. Returns a short id. */
@@ -26,6 +26,10 @@ export function runStatusLineCommand(workdir, command, info, timeoutMs = 4000) {
26
26
  const first = String(out).split(/\r?\n/)[0].trim();
27
27
  resolve(first);
28
28
  };
29
+ // Only the FIRST LINE is shown, so there is no reason to keep collecting a
30
+ // big blob: a chatty command (a JSON dump, `ls -R`) would grow `out`
31
+ // unboundedly and the status row then repainted megabytes. Stop appending
32
+ // once a short line is buffered (the rest of that line does not matter).
29
33
  const timer = setTimeout(() => {
30
34
  try {
31
35
  child.kill();
@@ -33,13 +37,30 @@ export function runStatusLineCommand(workdir, command, info, timeoutMs = 4000) {
33
37
  catch { }
34
38
  finish();
35
39
  }, timeoutMs);
40
+ // A status line is a short badge: cap what is buffered so a chatty
41
+ // command cannot grow it (and the rendered row) without bound.
42
+ const MAX_OUT = 400;
36
43
  child.stdout?.on('data', (c) => {
37
- out += c.toString();
44
+ if (out.length >= MAX_OUT)
45
+ return;
46
+ // A chunk can be huge (one 64 KB read); clip it to the remaining space
47
+ // so the buffer never exceeds MAX_OUT. An unbounded `out` made the
48
+ // status row repaint megabytes on every tick.
49
+ out += c.toString().slice(0, MAX_OUT - out.length);
38
50
  });
39
51
  child.on('error', () => {
40
52
  clearTimeout(timer);
41
53
  finish();
42
54
  });
55
+ // 'end' on OUR stdout fires as soon as the writer closes the pipe. 'close'
56
+ // waits for the pipes to drain, so a command that backgrounds a daemon (a
57
+ // watcher, `npm run dev &`) — whose grandchild inherits the pipe — kept the
58
+ // promise pending until the 4s timeout on EVERY tick even though the line
59
+ // had already been printed.
60
+ child.stdout?.on('end', () => {
61
+ clearTimeout(timer);
62
+ finish();
63
+ });
43
64
  child.on('close', () => {
44
65
  clearTimeout(timer);
45
66
  finish();
@@ -7,7 +7,16 @@
7
7
  /** Collapse whitespace and clip to `max` chars (single line). */
8
8
  function oneLine(s, max = 80) {
9
9
  const t = String(s).replace(/\s+/g, ' ').trim();
10
- return t.length > max ? t.slice(0, max - 1) + '…' : t;
10
+ if (t.length <= max)
11
+ return t;
12
+ // Never cut a surrogate PAIR in half: the log line is shown to the operator
13
+ // (and captured in the transcript), and a lone high surrogate at the end is
14
+ // a broken code point. Same rule as truncateToolResult / capReport.
15
+ let cut = max - 1;
16
+ const last = t.charCodeAt(cut - 1);
17
+ if (last >= 0xd800 && last <= 0xdbff)
18
+ cut--;
19
+ return t.slice(0, cut) + '…';
11
20
  }
12
21
  function str(v) {
13
22
  return typeof v === 'string' ? v : '';
package/dist/tools.js CHANGED
@@ -10,6 +10,17 @@ import { createExtraTools } from './extraTools.js';
10
10
  // user-facing. They must be in ENGLISH (like Claude Code / Codex). Only the
11
11
  // strings the OPERATOR sees (help, spinner, service messages) are localized —
12
12
  // those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
13
+ // Clip a long line without leaving a lone HIGH surrogate: the cut is in
14
+ // UTF-16 units, so an emoji / astral CJK char on the boundary would lose its
15
+ // low half and reach the model as a broken code point (same rule as
16
+ // truncateToolResult / capReport / summarizeDiagnostics / bgTrim).
17
+ function clipLine(l, max) {
18
+ let cut = max;
19
+ const last = l.charCodeAt(cut - 1);
20
+ if (last >= 0xd800 && last <= 0xdbff)
21
+ cut--;
22
+ return l.slice(0, cut);
23
+ }
13
24
  export function createTools(workdir, { undo, todos, readOnly = false, subagents = false, } = {}) {
14
25
  const root = path.resolve(workdir);
15
26
  const safe = (p) => safePath(root, p);
@@ -109,7 +120,7 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
109
120
  const start = offset ?? 0;
110
121
  const end = limit ? start + limit : lines.length;
111
122
  const slice = lines.slice(start, end);
112
- const clip = (l) => l.length > 2000 ? l.slice(0, 2000) + ' …[line truncated]' : l;
123
+ const clip = (l) => l.length > 2000 ? clipLine(l, 2000) + ' …[line truncated]' : l;
113
124
  if (!numbered)
114
125
  return slice.map(clip).join('\n');
115
126
  const width = String(start + slice.length).length;
package/dist/undo.js CHANGED
@@ -77,31 +77,56 @@ export class UndoStore {
77
77
  const history = await this._readIndex();
78
78
  if (!history.length)
79
79
  return { ok: false, reason: 'empty' };
80
- const record = history.pop();
81
- if (!record)
82
- return { ok: false, reason: 'empty' };
83
- const { originalPath, existed, backupFile } = record;
84
- try {
80
+ // Walk back from the newest record and SKIP a dead one (its .bak vanished,
81
+ // e.g. ~/.zames/undo was pruned or a crash left a record without the file).
82
+ // The old code popped the dead record, failed, and never rewrote the index —
83
+ // so every later /undo hit the same record again and the earlier backups
84
+ // stayed unreachable.
85
+ while (history.length) {
86
+ const record = history.pop();
87
+ if (!record)
88
+ break;
89
+ const { originalPath, existed, backupFile } = record;
90
+ let data = null;
85
91
  if (existed && backupFile) {
86
- const data = await fs.readFile(backupFile);
87
- await fs.writeFile(originalPath, data);
88
92
  try {
89
- await fs.unlink(backupFile);
93
+ data = await fs.readFile(backupFile);
94
+ }
95
+ catch (e) {
96
+ if (e.code === 'ENOENT')
97
+ continue;
98
+ return { ok: false, reason: e.message };
90
99
  }
91
- catch { }
92
100
  }
93
- else {
94
- try {
95
- await fs.unlink(originalPath);
101
+ try {
102
+ if (data) {
103
+ await fs.writeFile(originalPath, data);
104
+ try {
105
+ await fs.unlink(backupFile);
106
+ }
107
+ catch { }
96
108
  }
97
- catch { }
109
+ else {
110
+ // existed === false: the agent CREATED the file, so undoing removes
111
+ // it again. ENOENT is the expected case (it is already gone).
112
+ try {
113
+ await fs.unlink(originalPath);
114
+ }
115
+ catch { }
116
+ }
117
+ await this._writeIndex(history);
118
+ return { ok: true, record };
119
+ }
120
+ catch (e) {
121
+ // A real failure (permissions, a directory in place of the file): put
122
+ // the record back so a retry after fixing the cause still works.
123
+ history.push(record);
124
+ await this._writeIndex(history);
125
+ return { ok: false, reason: e.message };
98
126
  }
99
- await this._writeIndex(history);
100
- return { ok: true, record };
101
- }
102
- catch (e) {
103
- return { ok: false, reason: e.message };
104
127
  }
128
+ await this._writeIndex(history);
129
+ return { ok: false, reason: 'empty' };
105
130
  }
106
131
  async list(count = 10) {
107
132
  const history = await this._readIndex();
package/dist/web.js CHANGED
@@ -125,6 +125,49 @@ export function parseLegacyIpv4(host) {
125
125
  n += nums[nums.length - 1];
126
126
  return n >>> 0;
127
127
  }
128
+ // Collapse an IPv6 literal to the canonical `a:b:c:d:e:f:g:h` hex form so the
129
+ // prefix checks below see the SAME address whether it was written short
130
+ // (`::1`, `::ffff:7f00:1`) or fully expanded (`0:0:0:0:0:0:0:1`,
131
+ // `0:0:0:0:0:ffff:7f00:1`). A text-prefix match treated the expanded form as
132
+ // PUBLIC, which let the SSRF guard reach loopback. Returns null for a non-IPv6
133
+ // string or a malformed literal.
134
+ function expandIpv6(ip) {
135
+ const s = ip.toLowerCase();
136
+ if (!s.includes(':'))
137
+ return null;
138
+ // An embedded dotted IPv4 tail (::ffff:127.0.0.1) is folded into two hex
139
+ // groups first, so the split below has a uniform shape.
140
+ const dotted = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
141
+ let body = s;
142
+ if (dotted) {
143
+ const [a, b, c, d] = dotted.slice(2).map(Number);
144
+ if ([a, b, c, d].some((n) => n > 255))
145
+ return null;
146
+ const hi = ((a << 8) | b).toString(16);
147
+ const lo = ((c << 8) | d).toString(16);
148
+ body = dotted[1] + hi + ':' + lo;
149
+ }
150
+ const halves = body.split('::');
151
+ if (halves.length > 2)
152
+ return null;
153
+ const head = halves[0] ? halves[0].split(':') : [];
154
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
155
+ const groups = [];
156
+ for (const g of [...head, ...tail]) {
157
+ if (!/^[0-9a-f]{1,4}$/.test(g))
158
+ return null;
159
+ groups.push(g);
160
+ }
161
+ const missing = 8 - groups.length - (halves.length === 2 ? 0 : 0);
162
+ if (halves.length === 2) {
163
+ if (missing < 0)
164
+ return null;
165
+ return [...head, ...Array(missing).fill('0'), ...tail].map((g) => parseInt(g, 16));
166
+ }
167
+ if (groups.length !== 8)
168
+ return null;
169
+ return groups.map((g) => parseInt(g, 16));
170
+ }
128
171
  export function isPrivateIp(ip) {
129
172
  const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
130
173
  if (v4) {
@@ -158,6 +201,36 @@ export function isPrivateIp(ip) {
158
201
  }
159
202
  }
160
203
  const low = ip.toLowerCase();
204
+ // Judge the EXPANDED form so `0:0:0:0:0:0:0:1` is treated exactly like
205
+ // `::1` (a text-prefix match called the expanded literal public).
206
+ const v6 = expandIpv6(ip);
207
+ if (v6) {
208
+ const allZero = v6.every((g) => g === 0);
209
+ const loopback = v6.slice(0, 7).every((g) => g === 0) && v6[7] === 1;
210
+ if (allZero || loopback)
211
+ return true;
212
+ const g0 = v6[0];
213
+ // fe80::/10 link-local, fec0::/10 deprecated site-local.
214
+ if ((g0 & 0xffc0) === 0xfe80 || (g0 & 0xffc0) === 0xfec0)
215
+ return true;
216
+ // fc00::/7 unique-local.
217
+ if ((g0 & 0xfe00) === 0xfc00)
218
+ return true;
219
+ // ff00::/8 multicast is not a public unicast destination either.
220
+ if ((g0 & 0xff00) === 0xff00)
221
+ return true;
222
+ // IPv4-mapped ::ffff:a.b.c.d — judge the embedded address.
223
+ const mappedV4 = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 0xffff;
224
+ if (mappedV4) {
225
+ return isPrivateIp((v6[6] >> 8) +
226
+ '.' +
227
+ (v6[6] & 0xff) +
228
+ '.' +
229
+ (v6[7] >> 8) +
230
+ '.' +
231
+ (v6[7] & 0xff));
232
+ }
233
+ }
161
234
  if (low === '::1' || low === '::')
162
235
  return true;
163
236
  // IPv6 link-local is fe80::/10, i.e. fe80..febf (the old `startsWith('fe80')`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.78.0",
3
+ "version": "2.78.2",
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": {