zames_pro 2.66.1 → 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 CHANGED
@@ -7,16 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.67.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - **agent:** one-shot exit code, recursive self-review, MCP dedupe/truncate/plan-filter, transcript flush (N3/N4/N5/N9/N20/N21)
15
+
16
+ ### Fixed
17
+
18
+ - **input:** CSI-u Delete, visual-row Up/Down, ESC disambiguation, Ctrl+L/PageUp (N27/N28/N29/N30)
19
+ - **security:** close shell-injection, symlink-escape and SSRF-redirect holes (N1/N2/N6/N7/N17/N18/N19/N22/N23)
20
+
21
+ ### Fixed
22
+
23
+ - **tools:** Edit/MultiEdit no longer corrupt dollar patterns (`$&`, `$$`, `$1`)
24
+ in `new_string` — the replacement now uses the functional form instead of a
25
+ string replacer.
26
+ - **tools:** Grep and findstr are run through `execFile` with an argument
27
+ array instead of a shell string, so a `$(...)` pattern is no longer executed
28
+ as a command.
29
+ - **git:** GitShow/GitDiff/GitAdd/GitPush/GitCommit now use `execFile`; `ref`
30
+ and `path` are validated (a leading `-` is rejected) — command/option
31
+ injection through the arguments of read-only tools is closed.
32
+ - **sandbox:** file tools resolve the real path (`fs.realpath`) and reject a
33
+ symlink pointing outside the working directory.
34
+ - **shell:** `assertCommandInsideRoot` expands `~`/`$HOME`, sees a `cd` inside
35
+ a subshell/backticks and rejects `eval` with `cd`.
36
+ - **plan mode:** MCP tools are now filtered for read-only too (mutating verbs
37
+ are dropped).
38
+ - **self-review:** the snapshot and diff/apply walk src/ subdirectories
39
+ recursively (`i18n/`, `input/`) — they used to be silently missing from the
40
+ snapshot.
41
+ - **one-shot `--task`:** on an agent error / iteration limit / watchdog the
42
+ process now exits with code 1 instead of 0.
43
+ - **input:** Ctrl+Delete and Delete now work under the CSI-u (kitty) protocol:
44
+ `CSI 3;5u`/`3;3u`/`3u`.
45
+ - **input:** Up/Down move the cursor across VISUAL rows of a wrapped line, not
46
+ only across logical lines.
47
+ - **input:** a lone ESC no longer aborts the generation when a control
48
+ sequence arrives in two chunks — a short timeout disambiguation.
49
+ - **input:** Ctrl+L clears the screen and PageUp/PageDown page the suggestion
50
+ list; an unknown control sequence is logged in debug instead of being
51
+ swallowed silently.
52
+ - **MCP:** duplicate tool names are disambiguated with a numeric suffix so the
53
+ second tool is not silently unreachable.
54
+ - **MCP:** a tool result is truncated with an explicit marker — a single call
55
+ (browser_snapshot and the like) no longer floods the context.
56
+ - **transcript:** `close()` waits for the flush (it returns a promise) so the
57
+ final events reach the file on the exit paths.
58
+ - **web:** `WebFetch` re-validates every redirect hop against the SSRF guard
59
+ and checks the final URL of a headless render.
60
+ - **permissions:** a `path` rule now also matches the file headers of an
61
+ `ApplyPatch` and path-looking arguments of MCP tools.
62
+ - **scheduler:** `nextCronTime` parses the cron expression once instead of on
63
+ every scanned minute.
64
+ - **git:** `formatGitContext` picks a code fence longer than any backtick run
65
+ in the preview, so a filename with backticks cannot break the markdown.
66
+ - **fsutil:** secrets and state are written with 0600 permissions;
67
+ `writeFileAtomic` fsyncs the file before rename (and the directory after)
68
+ for durability on a power loss.
69
+
70
+ ### Changed
71
+
72
+ - **docs:** SECURITY.md updated for the permissions policy, the symlink guard
73
+ and the plan-mode MCP caveat; DEVELOPING.md module sizes refreshed.
74
+ - **cleanup:** removed the dead exports `listConfiguredServers`,
75
+ `builtinSkills` and `findFileByName`.
76
+ - **tests:** the MCP result cap, `htmlToText` and the DuckDuckGo parser are
77
+ now covered, and `web.ts`/`spinner.ts` gained coverage floors.
78
+
79
+ ### Security
80
+
81
+ - Shared `shellQuote` helper for arguments passed to the shell (single quotes
82
+ with internal quotes doubled).
83
+
10
84
  ## [2.66.1] - 2026-10-07
11
85
 
12
86
  ### Changed
13
87
 
14
- - README: русский перевод теперь свёрнут в `<details>` прямо в `README.md`,
15
- поэтому его можно читать на странице npm, не уходя на GitHub. npm рендерит
16
- readme тем же GitHub Flavored Markdown, что и GitHub, а отдельный
17
- `README.ru.md` на npm-странице не виден (у npm один readme на пакет).
18
- Обе языковые версии собираются из `docs/readme.{en,ru}.md` скриптом
19
- `npm run build:readme`; `test/readme-built.test.ts` следит за рассинхроном.
88
+ - README: the Russian translation is now folded into `<details>` directly in
89
+ `README.md`, so it can be read on the npm page without leaving GitHub. npm
90
+ renders the readme with the same GitHub Flavored Markdown as GitHub, and a
91
+ separate `README.ru.md` is not visible on the npm page (npm has one readme
92
+ per package). Both language versions are assembled from
93
+ `docs/readme.{en,ru}.md` by `npm run build:readme`;
94
+ `test/readme-built.test.ts` guards against drift.
20
95
 
21
96
  ## [2.66.0] - 2026-10-07
22
97
 
@@ -36,151 +111,148 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
36
111
 
37
112
  ### Fixed
38
113
 
39
- - Длинные строки preview больше не создают горизонтальный скролл в
40
- терминале: `toolCall`/`toolResult` (LineEditor и спиннер) теперь режут
41
- превью по ВИДИМОЙ ШИРИНЕ терминала (`truncateToWidth`), а не по числу
42
- символов. Раньше длинная команда/результат печатались шире экрана.
43
- - Ctrl+Delete / Ctrl+Backspace (и Alt+Delete / Alt+Backspace) теперь удаляют
44
- слово в строке ввода. Раньше эти escape-последовательности попадали в
45
- общий пропуск клавиш и ничего не делали. Поддержаны варианты: `ESC[3;5~`
46
- (xterm Ctrl+Delete), `ESC[3;3~` (Alt+Delete), `ESC DEL`/`ESC 8`
47
- (Alt/Ctrl+Backspace) и kitty-протокол `ESC[127;5u` / `ESC[127;3u`.
48
- Новый метод `_deleteWordRight()` + тест `test/editor-word-delete.test.ts`.
49
- - Меню `/config` теперь держит ВЫБРАННЫЙ пункт на экране: окно строится
50
- «назад» от курсора с учётом строк, которые занимают заголовки групп
51
- (раньше индексный `scrollTop` мог вытолкнуть выбранную строку вниз за
52
- пределы окна).
53
- - `/new` сбрасывает счётчик контекста: `_lastTokenUsage` копится НА ЧАТ,
54
- и без сброса в пустом чате оставался старый `ctx: 302k · 30%` до первой
55
- отправки. Тест в `test/newchat-verify.test.ts`.
114
+ - Long preview lines no longer create a horizontal scrollbar in the terminal:
115
+ `toolCall`/`toolResult` (LineEditor and spinner) now clip the preview by the
116
+ VISIBLE terminal width (`truncateToWidth`) instead of by character count. A
117
+ long command/result used to print wider than the screen.
118
+ - Ctrl+Delete / Ctrl+Backspace (and Alt+Delete / Alt+Backspace) now delete a
119
+ word in the input line. These escape sequences used to fall through the
120
+ generic key skip and did nothing. Supported variants: `ESC[3;5~` (xterm
121
+ Ctrl+Delete), `ESC[3;3~` (Alt+Delete), `ESC DEL`/`ESC 8`
122
+ (Alt/Ctrl+Backspace) and the kitty protocol `ESC[127;5u` / `ESC[127;3u`.
123
+ New `_deleteWordRight()` method + `test/editor-word-delete.test.ts`.
124
+ - The `/config` menu now keeps the SELECTED item on screen: the window is
125
+ built "backwards" from the cursor, taking into account the rows the group
126
+ headings occupy (previously an index-based `scrollTop` could push the
127
+ selected row below the window).
128
+ - `/new` resets the context counter: `_lastTokenUsage` accumulates PER CHAT,
129
+ and without a reset an empty chat kept showing the old `ctx: 302k · 30%`
130
+ until the first send. Test in `test/newchat-verify.test.ts`.
56
131
 
57
132
  ## [2.65.0] - 2026-10-07
58
133
 
59
134
  ### Changed
60
135
 
61
- - README-скриншот `docs/demo.png` перегенерирован на английском языке
62
- (раньше был на русском). Воспроизводимый генератор
63
- `npm run render-demo` (`scripts/render-demo.mjs`) рендерит реальную
64
- раскладку редактора и палитру темы через headless Chromium с
65
- изолированным профилем.
66
- - README: ASCII-схема «как это работает» заменена на диаграмму Mermaid
67
- (стрелки не «плывут») плюс текстовое описание словами.
68
- - Убраны неиспользуемые файлы: `logo.jpg` (в README используется только
69
- `logo-small.jpg`) и заготовка `docs/demo.tape` (VHS-рендер так и не был
70
- сгенерирован). `logo.jpg` удалён и из списка `files` в package.json.
136
+ - The README screenshot `docs/demo.png` was regenerated in English (it used to
137
+ be in Russian). The reproducible generator `npm run render-demo`
138
+ (`scripts/render-demo.mjs`) renders the real editor layout and theme palette
139
+ through headless Chromium with an isolated profile.
140
+ - README: the ASCII "how it works" diagram was replaced by a Mermaid diagram
141
+ (the arrows no longer "float") plus a textual description in words.
142
+ - Removed unused files: `logo.jpg` (only `logo-small.jpg` is used in the
143
+ README) and the `docs/demo.tape` draft (the VHS render was never generated).
144
+ `logo.jpg` was also removed from the `files` list in package.json.
71
145
 
72
146
  ### Fixed
73
147
 
74
- - Перерисовка терминала после ресайза окна: относительный стирающий
75
- `ESC[n A` после изменения размера попадал на устаревшую строку (терминал
76
- переформатировал переносы), из-за чего статус/инпут рисовались не там.
77
- Теперь после ресайза блок заново «прибивается» к низу (home + padding),
78
- история скроллбэка сохраняется. Тест в `test/terminal-drift.test.ts`.
79
- - Живая смена языка (`/config lang`) не доходила до двух «одноразово
80
- созданных» объектов: спиннер думающих фраз брал язык по умолчанию
81
- (`randomThinkingPhrase()` вместо `randomThinkingPhrase(this.locale)`),
82
- а `DeepSeekBrowser` печатал свои уведомления (`ds.*`) на языке,
83
- зафиксированном при старте. Из-за этого при английском UI всплывали
84
- русские «Структурирую мысли…» и «⏳ DeepSeek оборвал ответ…». Добавлен
85
- `DeepSeekBrowser.setLocale()`, вызываемый из `setConfigRuntime`.
86
- - Меню `/config` теперь рисуется ОКНОМ по высоте терминала (раньше весь
87
- список из 49 полей выводился одним блоком). На невысоком терминале блок
88
- уходил верхними строками за экран, из-за чего восстановление курсора
89
- `ESC[lastRows A` попадало не на ту строку и навигация стрелками «не
90
- скроллила» список. Заодно исправлена мёртвая регулярка снятия ANSI
91
- (лишний обратный апостроф) в `src/config-menu.ts`.
148
+ - Terminal repaint after a window resize: the relative erase `ESC[n A` after a
149
+ size change landed on a stale row (the terminal re-flowed the wraps), so the
150
+ status/input were drawn in the wrong place. After a resize the block is
151
+ re-pinned to the bottom (home + padding), and the scrollback history is kept.
152
+ Test in `test/terminal-drift.test.ts`.
153
+ - A live language change (`/config lang`) did not reach two "created once"
154
+ objects: the thinking-phrase spinner took the default language
155
+ (`randomThinkingPhrase()` instead of `randomThinkingPhrase(this.locale)`),
156
+ and `DeepSeekBrowser` printed its notices (`ds.*`) in the language fixed at
157
+ startup. So with an English UI Russian thinking/notice phrases surfaced.
158
+ Added `DeepSeekBrowser.setLocale()`, called from `setConfigRuntime`.
159
+ - The `/config` menu is now drawn as a WINDOW sized to the terminal height
160
+ (previously the whole list of 49 fields was printed as one block). On a short
161
+ terminal the block scrolled off the top, so restoring the cursor with
162
+ `ESC[lastRows A` landed on the wrong row and arrow navigation "did not
163
+ scroll" the list. Also fixed a dead ANSI-strip regex (a stray backtick) in
164
+ `src/config-menu.ts`.
92
165
 
93
166
  ## [2.64.1] - 2026-10-07
94
167
 
95
168
  ### Changed
96
169
 
97
- - README: добавлен скриншот терминальной сессии (`docs/demo.png`) под
98
- шапкой, чтобы интерфейс был виден сразу; скрипт воспроизводимого
99
- VHS-демо (`docs/demo.tape`) для будущего GIF.
170
+ - README: added a terminal session screenshot (`docs/demo.png`) under the
171
+ header so the interface is visible immediately; a reproducible VHS demo
172
+ script (`docs/demo.tape`) for a future GIF.
100
173
 
101
174
  ## [2.64.0] - 2026-10-06
102
175
 
103
176
  ### Added
104
177
 
105
- - Генератор черновика CHANGELOG из conventional-commits (BACKLOG D2):
106
- `npm run changelog:draft [ref]` печатает готовый блок для «Unreleased» из
107
- коммитов после последнего тега (или указанного ref). Чистый модуль
178
+ - A CHANGELOG draft generator from conventional commits (BACKLOG D2):
179
+ `npm run changelog:draft [ref]` prints a ready "Unreleased" block from the
180
+ commits after the last tag (or the given ref). Pure module
108
181
  `src/changelog.ts` (`parseConventionalCommit` / `groupCommits` /
109
- `renderChangelogDraft`, покрыт тестами). Файл `CHANGELOG.md` НЕ
110
- перезаписывается автоматически — черновик ревьюится вручную.
182
+ `renderChangelogDraft`, unit-tested). The `CHANGELOG.md` file is NOT
183
+ overwritten automatically — the draft is reviewed by hand.
111
184
 
112
185
  ### Changed
113
186
 
114
- - Рефакторинг (BACKLOG C3, шаг 1): разрешение путей вложения и инлайн
115
- `@file`-ссылок вынесены из `src/index.ts` в `src/attach-refs.ts`
116
- (`resolveAttachPath` / `inlineAtRefs`) — теперь это отдельный тестируемый
117
- модуль.
187
+ - Refactor (BACKLOG C3, step 1): attachment path resolution and `@file`
188
+ reference inlining were moved out of `src/index.ts` into
189
+ `src/attach-refs.ts` (`resolveAttachPath` / `inlineAtRefs`) — now a separate
190
+ testable module.
118
191
 
119
192
  ## [2.63.1] - 2026-10-06
120
193
 
121
194
  ### Fixed
122
195
 
123
- - Мигание таймера в статусе паузы перед отправкой: `sendPause()` (и
124
- `LineEditor`, и non-TTY `SpinnerUI`) перерисовывал статус БЕЗ хвоста
125
- «elapsed»/бейджа задач, а тик анимации рисовал его С ним — раз в секунду
126
- подпись `· 1m 50s` исчезала и появлялась. Теперь обновление собирает тот же
127
- хвост, что и тик. Регрессионный тест в test/compact-statusline.test.ts.
196
+ - Timer flicker in the pre-send pause status: `sendPause()` (both `LineEditor`
197
+ and the non-TTY `SpinnerUI`) repainted the status WITHOUT the
198
+ "elapsed"/task-badge tail, while the animation tick drew it WITH it — once a
199
+ second the `· 1m 50s` label disappeared and reappeared. The update now builds
200
+ the same tail as the tick. Regression test in test/compact-statusline.test.ts.
128
201
 
129
202
  ### Changed
130
203
 
131
- - README: центрированная шапка, логотип крупнее (640px-ассет, 430px показ),
132
- диаграмма «How it works», оглавление и секция Links.
133
- - Dependabot: мажорные обновления игнорируются (`semver-major`), расписание
134
- раз в месяц, авто-мерж только для patch/minor и только для PR бота
135
- (проверка автора и ветки); workflow авто-мержа. Ветка `master` защищена
136
- ruleset: PR обязателен, required-check — стабильный job `ci`.
204
+ - README: centered header, larger logo (640px asset, 430px display), a "How
205
+ it works" diagram, a table of contents and a Links section.
206
+ - Dependabot: major updates are ignored (`semver-major`), monthly schedule,
207
+ auto-merge only for patch/minor and only for bot PRs (author and branch
208
+ checks); auto-merge workflow. The `master` branch is protected by a ruleset:
209
+ PR required, required check is the stable `ci` job.
137
210
 
138
211
  ## [2.63.0] - 2026-10-06
139
212
 
140
213
  ### Added
141
214
 
142
- - Реальные подтверждения действий (BACKLOG C1): политика approval в
143
- `.zames/permissions.json` (`default` + `rules` с regexp по `tool`/`command`/
144
- `path` и действием `allow`|`deny`|`ask`). Вызывается в `agent-loop.ts` ПЕРЕД
145
- каждым инструментом: `deny` блокирует вызов, `ask` спрашивает оператора через
146
- `onAskPermission` (в TTY — интерактивный промпт над инпут-линией, в non-TTY —
147
- запрет). Новый чистый модуль `src/permissions.ts` + тесты
148
- (test/permissions.test.ts, test/permissions-loop.test.ts).
149
-
150
- - Кастомные команды: аргументы и подсказки (BACKLOG B7). Frontmatter
151
- `argument-hint:` показывается в списке «/» и в `/help` (не вставляется в
152
- строку ввода), а `arguments:` объявляет обязательные позиционные аргументы.
153
- В теле команды подставляются `$1 $2`, именованные `$name`, а также прежние
154
- `{{args}}`/`$ARGUMENTS`. Если обязательные аргументы не переданы — команда
155
- не уходит в чат, печатается подсказка. Новые чистые хелперы
215
+ - Real action approvals (BACKLOG C1): an approval policy in
216
+ `.zames/permissions.json` (`default` + `rules` with regexp on
217
+ `tool`/`command`/`path` and an `allow`|`deny`|`ask` action). Evaluated in
218
+ `agent-loop.ts` BEFORE every tool: `deny` blocks the call, `ask` prompts the
219
+ operator via `onAskPermission` (in a TTY — an interactive prompt above the
220
+ input line, in non-TTY — denied). New pure module `src/permissions.ts` +
221
+ tests (test/permissions.test.ts, test/permissions-loop.test.ts).
222
+
223
+ - Custom commands: arguments and hints (BACKLOG B7). Frontmatter
224
+ `argument-hint:` is shown in the "/" list and in `/help` (not inserted into
225
+ the input line), and `arguments:` declares mandatory positional arguments.
226
+ `$1 $2`, named `$name` and the previous `{{args}}`/`$ARGUMENTS` are
227
+ substituted into the command body. If the required arguments are missing the
228
+ command is not sent to the chat and a hint is printed. New pure helpers
156
229
  `splitCommandArgs()` / `expandCommandArgs()` / `missingCommandArgs()`
157
- (src/commands.ts, покрыты тестами).
158
-
159
- - Path-scoped правила (BACKLOG B6): вложенные `AGENTS.md`/`MEMORY.md` из
160
- подпапок, которых касается задача, подтягиваются автоматически. Текст задачи
161
- сканируется на path-токены, для найденных директорий (и их предков ниже
162
- рабочей) читаются ближайшие инструкции и рендерятся отдельной секцией
163
- `## Scoped instructions (...)` — явно помечены как действующие только для
164
- этих файлов. `loadProjectContext(workdir, touchPaths?)` и
165
- `renderContextSection()` (src/context.ts, src/system-prompt.ts; покрыто
166
- тестами).
167
-
168
- - `@file`-ссылки в задаче (BACKLOG B5): `реши задачу @src/browser.ts`
169
- подставляет содержимое указанного файла прямо в задачу, экономя отдельный
170
- ход агента на чтение. Распознаётся `@path` на границе слова (в начале строки
171
- или после пробела/скобки/кавычки), с расширением файла; `user@host` и
172
- декораторы (`@Component`) не трогаются. Существующие файлы инлайнятся
173
- (лимит 60 КБ на файл, 200 КБ суммарно — сверх этого усечение с пометкой),
174
- несуществующие остаются как есть. Хелпер `extractAtFileRefs()` в
175
- `src/path-token.ts` (чистый, покрыт тестами).
230
+ (src/commands.ts, unit-tested).
231
+
232
+ - Path-scoped rules (BACKLOG B6): nested `AGENTS.md`/`MEMORY.md` from the
233
+ subdirectories the task touches are pulled in automatically. The task text is
234
+ scanned for path tokens, the nearest instructions are read for the found
235
+ directories (and their ancestors below the working dir) and rendered as a
236
+ separate `## Scoped instructions (...)` section — explicitly marked as
237
+ applying only to those files. `loadProjectContext(workdir, touchPaths?)` and
238
+ `renderContextSection()` (src/context.ts, src/system-prompt.ts; unit-tested).
239
+
240
+ - `@file` references in a task (BACKLOG B5): `solve the task @src/browser.ts`
241
+ inlines the contents of the referenced file right into the task, saving a
242
+ separate agent turn to read it. `@path` is recognized at a word boundary (at
243
+ the start of a line or after a space/bracket/quote) with a file extension;
244
+ `user@host` and decorators (`@Component`) are not touched. Existing files are
245
+ inlined (60 KB per file, 200 KB total — beyond that a truncation marker),
246
+ missing ones stay as-is. Helper `extractAtFileRefs()` in `src/path-token.ts`
247
+ (pure, unit-tested).
176
248
 
177
249
  ### Changed
178
250
 
179
- - `AGENTS.md` уменьшен (BACKLOG C2): глубокие root-cause разборы («агент
180
- остановился», чтение ответа из DOM, send-хуки, `LineEditor`, вложения) и
181
- терминальная механика вынесены в `docs/DESIGN-NOTES.md` (progressive
182
- disclosure — не грузится в каждую задачу). В AGENTS.md остались действующие
183
- правила и краткая выжимка со ссылкой.
251
+ - `AGENTS.md` trimmed (BACKLOG C2): the deep root-cause write-ups ("the agent
252
+ stopped", reading the answer from the DOM, send hooks, `LineEditor`,
253
+ attachments) and the terminal mechanics were moved to
254
+ `docs/DESIGN-NOTES.md` (progressive disclosure — not loaded into every task).
255
+ AGENTS.md keeps the acting rules plus a short summary with a pointer.
184
256
 
185
257
  ## [2.62.0]
186
258
 
@@ -399,23 +399,6 @@ export function writeClipboardText(text) {
399
399
  }
400
400
  return { ok: false, via: candidates.map((c) => c.bin).join('/') };
401
401
  }
402
- export async function findFileByName(workdir, name) {
403
- const candidate = path.resolve(workdir, name);
404
- if (await fs.stat(candidate).catch(() => null))
405
- return candidate;
406
- try {
407
- const { glob } = await import('fs/promises');
408
- for await (const f of glob('**/' + name, { cwd: workdir })) {
409
- const abs = path.resolve(workdir, f);
410
- const rel = path.relative(workdir, abs);
411
- if (rel.startsWith('..') || path.isAbsolute(rel))
412
- continue;
413
- return abs;
414
- }
415
- }
416
- catch { }
417
- return null;
418
- }
419
402
  // Read file paths from the Windows clipboard (copied files, CF_HDROP) and
420
403
  // translate them to WSL paths. On WSL the user often copies a FILE, not an
421
404
  // image. Returns [] on non-WSL / when nothing is there.
package/dist/context.js CHANGED
@@ -58,9 +58,6 @@ const BUILTIN_SKILLS = [
58
58
  '4. Do NOT change code. Answer in the operator language.',
59
59
  },
60
60
  ];
61
- export function builtinSkills() {
62
- return BUILTIN_SKILLS;
63
- }
64
61
  const MAX_FILE_BASE = 60000;
65
62
  const MAX_TOTAL = 240000;
66
63
  const CRLF_RE = /\r\n/g;
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import { safePath } from './sandbox.js';
3
4
  // Extra tools that bring zames closer to Claude Code / Codex CLI:
4
5
  // * LS - list a directory (Claude Code has LS; Codex has list_dir)
5
6
  // * MultiEdit - several edits to ONE file applied atomically (Claude Code)
@@ -171,14 +172,7 @@ export function applyUpdateHunk(content, hunkLines) {
171
172
  export function createExtraTools(workdir, { undo, todos } = {}) {
172
173
  const todoStore = todos || defaultTodoStore;
173
174
  const root = path.resolve(workdir);
174
- const safe = (p) => {
175
- const resolved = path.resolve(root, p);
176
- const rel = path.relative(root, resolved);
177
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
178
- throw new Error('Access outside the working directory is forbidden: ' + p);
179
- }
180
- return resolved;
181
- };
175
+ const safe = (p) => safePath(root, p);
182
176
  // Guard against missing/placeholder required args: a missing `path` becomes
183
177
  // String(undefined) === "undefined" and would create a file literally named
184
178
  // "undefined" in the working directory.
@@ -271,9 +265,14 @@ export function createExtraTools(workdir, { undo, todos } = {}) {
271
265
  throw new Error(`edits[${i}]: string occurs ${occurrences} times. ` +
272
266
  'Make old_string more specific or pass replace_all=true.');
273
267
  }
274
- content = e.replace_all
275
- ? content.split(oldStr).join(newStr)
276
- : content.replace(oldStr, newStr);
268
+ if (e.replace_all) {
269
+ content = content.split(oldStr).join(newStr);
270
+ }
271
+ else {
272
+ // Functional replacer: a string replacer would interpret $-patterns
273
+ // ($&, $$, $1) inside newStr and corrupt the written text.
274
+ content = content.replace(oldStr, () => newStr);
275
+ }
277
276
  }
278
277
  if (undo)
279
278
  await undo.backup(file);
package/dist/fsutil.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
2
3
  // One atomic writer shared by config, sessions and undo. Writing to a temp
3
4
  // file in the SAME directory and renaming over the target is atomic on POSIX
4
5
  // and Windows (same volume), so a crash or Ctrl+C mid-write can never leave a
@@ -8,10 +9,46 @@ import fs from 'fs';
8
9
  // Kept in its own module because the pattern used to be copy-pasted in at
9
10
  // least three places; drift between the copies is exactly how one of them
10
11
  // silently loses its atomicity.
12
+ //
13
+ // The file is created 0o600: ~/.zames/config.json holds the
14
+ // browser password in clear text alongside sessions/undo history, and the
15
+ // default umask (022) would make them readable by other users on a
16
+ // multi-user machine. We also chmod the target after rename because a
17
+ // pre-existing file keeps its old mode (rename does not change the inode).
11
18
  export function writeFileAtomic(file, data) {
12
19
  const tmp = file + '.tmp-' + process.pid;
13
- fs.writeFileSync(tmp, data);
20
+ // fsync the file BEFORE rename: without it a power loss can leave the
21
+ // renamed name pointing at an empty/partial inode (rename itself is atomic,
22
+ // but the data blocks may not have reached the disk yet).
23
+ const fd = fs.openSync(tmp, 'w', 0o600);
24
+ try {
25
+ fs.writeFileSync(fd, data);
26
+ fs.fsyncSync(fd);
27
+ }
28
+ finally {
29
+ fs.closeSync(fd);
30
+ }
14
31
  fs.renameSync(tmp, file);
32
+ // fsync the DIRECTORY so the rename entry itself is durable.
33
+ try {
34
+ const dirFd = fs.openSync(path.dirname(file), 'r');
35
+ try {
36
+ fs.fsyncSync(dirFd);
37
+ }
38
+ finally {
39
+ fs.closeSync(dirFd);
40
+ }
41
+ }
42
+ catch {
43
+ // Some platforms/filesystems do not allow opening a directory; best-effort.
44
+ }
45
+ // Best-effort: on Windows chmod is largely a no-op and can throw.
46
+ try {
47
+ fs.chmodSync(file, 0o600);
48
+ }
49
+ catch {
50
+ // ignore
51
+ }
15
52
  }
16
53
  export function writeJsonAtomic(file, value) {
17
54
  writeFileAtomic(file, JSON.stringify(value, null, 2) + String.fromCharCode(10));