acuvo-code 0.2.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,928 @@
1
+ /**
2
+ * THE LOCAL FILESYSTEM EXECUTOR — the boundary that makes a terminal coding
3
+ * agent safe to run.
4
+ *
5
+ * ── WHY THIS FILE IS THE MOST IMPORTANT ONE IN THE PACKAGE ──────────────────
6
+ * Every other tool in this repo hands model output to a browser iframe or a
7
+ * cloud sandbox, where the blast radius of a bad path is a broken preview. Here
8
+ * the model's string becomes a real `writeFileSync` on Roman's laptop. A single
9
+ * accepted `../../.ssh/authorized_keys` is not a rendering bug, it is a
10
+ * compromise — and the string is chosen by a language model, which is to say by
11
+ * something with no notion of what is outside the project.
12
+ *
13
+ * ── THE RULE WAS INHERITED, AND THE INHERITANCE WAS WRONG ───────────────────
14
+ * `console/lib/generated-files.ts:safeFilePath` settled the argument for
15
+ * generated projects, and this file copied its answer verbatim:
16
+ *
17
+ * WHITELIST the characters a segment may contain (`^[A-Za-z0-9._-]+$`), never
18
+ * blacklist the traversal spellings.
19
+ *
20
+ * ⚠️ THAT RULE IS CORRECT FOR A ZIP OF WEB ASSETS AND WRONG FOR A CODEBASE, and
21
+ * this file spent its first months being wrong. `safeFilePath`'s whole universe
22
+ * is `index.html` and `js/app.js`. A real repository contains
23
+ * `app/[tenantSlug]/page.tsx`, `app/(dashboard)/layout.tsx`, `src/[...slug]/`,
24
+ * `@modal/`, `My Component.tsx` and `café.js`. Measured on `console/` on
25
+ * 2026-08-10: **565 of 2,077 tracked files — 27% — were unopenable.**
26
+ *
27
+ * ⭐ AND THE TOOL DISAGREED WITH ITSELF. `search.mjs` walks the disk directly,
28
+ * so `find_files` and `search_text` RETURNED those paths while `read_file`
29
+ * refused them. The model was handed a filename it could never open, burned
30
+ * rounds retrying spellings, gave up — and the session still exited 0 with
31
+ * `ok: true`. A capability hole that reports success is worse than a crash.
32
+ *
33
+ * ── SO THE CHARACTER RULE IS NOW A DENYLIST, AND THAT IS SAFE ───────────────
34
+ * It is safe because the character rule was never what held the line. Escaping
35
+ * the workspace is refused STRUCTURALLY, further down: the `..` segment check,
36
+ * then `resolveInWorkspace`'s realRoot + `isInside` + realpath-of-the-deepest-
37
+ * existing-ancestor. `../outside`, `..\outside`, `src/../../outside`,
38
+ * `/etc/passwd`, `C:/Windows/win.ini`, `\\server\share`, an embedded NUL and a
39
+ * planted junction were each re-verified against that half with the whitelist
40
+ * gone. The `isInside` call below is documented as the assertion that catches a
41
+ * loosening of this regex — this IS that loosening, so it stays.
42
+ *
43
+ * What the denylist now refuses is a different hazard entirely: **filenames
44
+ * Windows cannot store, or stores and then cannot delete.** `< > : " | ? * \ /`
45
+ * are illegal outright; `...`, `trail.` and `trail ` are creatable through the
46
+ * API and then undeletable through Explorer, cmd and PowerShell; `nul` and
47
+ * `com1.txt` are MS-DOS devices and open the device instead of the file. Every
48
+ * one of those passed the old whitelist. An agent that leaves undeletable
49
+ * litter in someone's project is a worse neighbour than one that refuses a name.
50
+ *
51
+ * ⚠️ THREE RULES NOW DIFFER FROM `safeFilePath`, NOT TWO.
52
+ * `safeFilePath` also demands a web-asset extension and a depth of ≤4, because
53
+ * its output is a static site bundle. Here the extension gate is dropped, the
54
+ * depth budget is widened, and — new — the character rule is a denylist rather
55
+ * than a whitelist. `console/lib/acuvo-code-workspace.test.ts` is the drift
56
+ * guard that asserts the shared half cannot diverge; its third divergence
57
+ * assertion still names only two and MUST be updated with this change, or it
58
+ * will be red for a reason that is no longer true.
59
+ *
60
+ * ── AND THE ONE `safeFilePath` NEVER HAD TO THINK ABOUT: SYMLINKS ───────────
61
+ * A purely lexical check is sufficient when the path is a key in a zip. It is
62
+ * NOT sufficient against a real filesystem: `notes` can be a symlink to
63
+ * `C:\Windows\System32`, and `notes/evil.dll` passes every character test ever
64
+ * written while landing squarely outside the project. So every resolved path is
65
+ * run through `realpathSync` on its deepest EXISTING ancestor and re-checked
66
+ * against the REAL root. A path component that does not exist yet cannot be a
67
+ * symlink, which is why checking the existing prefix is enough rather than
68
+ * merely convenient.
69
+ */
70
+
71
+ import { realpathSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync, renameSync, copyFileSync } from 'node:fs';
72
+ import { resolve, join, dirname, sep } from 'node:path';
73
+ /**
74
+ * ⭐ IMPORTED, NEVER RE-IMPLEMENTED — the rule search.mjs follows for the same
75
+ * function. secret-paths.mjs owns the one list of paths that must never leave this
76
+ * machine, and `moveFile` has to consult it or a rename becomes a way to
77
+ * relabel a credential into something committable.
78
+ */
79
+ import { refusedCommitPath } from './secret-paths.mjs';
80
+
81
+ /** Depth budget. Deep enough for a real source tree, bounded so a model cannot
82
+ * spray a thousand nested directories from one typo. */
83
+ export const MAX_DEPTH = 12;
84
+ /** A single path string longer than this is a mistake, not a filename. */
85
+ export const MAX_PATH_LENGTH = 255;
86
+ /** Refuse to hand the model a file large enough to blow the context budget. */
87
+ export const MAX_READ_BYTES = 200_000;
88
+ /** Refuse to write more than this in one call. */
89
+ export const MAX_WRITE_BYTES = 400_000;
90
+ /** A directory listing is context, not a database dump. */
91
+ export const MAX_LIST_ENTRIES = 400;
92
+
93
+ /**
94
+ * ⚠️ WRITE-ONLY REFUSALS. These directories are readable (a coding agent has
95
+ * every reason to read `node_modules` types or a git config) but must never be
96
+ * WRITTEN, because writing to them is remote code execution wearing a filename:
97
+ * `.git/hooks/pre-commit` runs on the owner's next commit, and a package inside
98
+ * `node_modules` runs on the next `npm run` of anything.
99
+ *
100
+ * This is the one rule here that is NOT about staying inside the project — it is
101
+ * about the fact that "inside the project" still contains loaded guns.
102
+ */
103
+ /**
104
+ * ── ⚠️⚠️ EVERY SEGMENT, NOT JUST THE FIRST ─────────────────────────────────
105
+ *
106
+ * This was `has(segments[0])` — index 0 only. Measured against a temp workspace,
107
+ * every one of these returned `{ok:true, created:true}` and landed on disk:
108
+ *
109
+ * packages/web/node_modules/vitest/dist/index.js
110
+ * apps/api/node_modules/.bin/anything
111
+ *
112
+ * A monorepo has a `node_modules` under every package, and a file written into
113
+ * one of them **executes on the next `npm run`** exactly as a root-level one
114
+ * does. The guard was defeated by a directory prefix.
115
+ *
116
+ * ── ⭐ AND WHY `.github/` IS DELIBERATELY *NOT* ON THIS LIST ────────────────
117
+ *
118
+ * ENTERPRISE.md §3.4 proposed adding `.github/`, `.husky/`, `.vscode/` and
119
+ * `.devcontainer/`. I am not doing that, and the reason is the line this set
120
+ * actually draws.
121
+ *
122
+ * These four are refused because **a diff never shows them**: `.git/` is
123
+ * internal, `node_modules/` `.next/` and `.vercel/` are git-ignored build and
124
+ * dependency trees. Code written there runs on the owner's next command having
125
+ * been reviewed by nobody, because there was nowhere for anybody to review it.
126
+ *
127
+ * `.github/workflows/`, `.husky/` and `.vscode/` are the opposite: **tracked,
128
+ * committed, and shown in every diff and pull request**. They are also things a
129
+ * user legitimately asks for — "add a CI workflow" is an ordinary request, and a
130
+ * coding agent that silently refuses it has failed correct work, which this
131
+ * package treats as worse than the risk it was avoiding. The protection there is
132
+ * review, and review is present by construction.
133
+ *
134
+ * ⚠️ If that trade is ever revisited, revisit it as a POLICY setting
135
+ * (`lib/policy.mjs` already owns opt-in restrictions) rather than by extending
136
+ * this set — otherwise the refusal has no way to be turned off by someone who
137
+ * meant it.
138
+ */
139
+ const WRITE_FORBIDDEN_ROOTS = new Set(['.git', 'node_modules', '.next', '.vercel']);
140
+
141
+ /**
142
+ * ── ⚠️⚠️ AND THE ONE DIRECTORY THAT DECIDES WHAT THIS AGENT MAY DO ──────────
143
+ *
144
+ * `.acuvo/` holds `mcp.json` (which NAMES THE PROGRAMS WE SPAWN),
145
+ * `commands.json` (which grants language ecosystems) and `policy.json` (the
146
+ * round and dollar ceilings). Proven against the real executor on 2026-08-13:
147
+ * `write_file('.acuvo/mcp.json', …)` succeeded, and the next run would have
148
+ * spawned the binary it named. That is the identical sentence `.git/` is
149
+ * already refused for — code executing on the owner's next command, in a
150
+ * directory nobody thinks to review — pointed at our own leash.
151
+ *
152
+ * ⚠️ NOT MERELY A PROMPT RULE. The system prompt does tell the model not to
153
+ * enable a preset for itself, and that is worth saying, but guidance is not a
154
+ * boundary: "it would not think of it" has never been a security control.
155
+ *
156
+ * ⚠️ WHY THIS ONE IS HARD-REFUSED RATHER THAN A POLICY SETTING, given the note
157
+ * above says new restrictions belong in policy.mjs so they can be turned off:
158
+ * that argument is about `.github/workflows` and `.vscode`, which a user
159
+ * LEGITIMATELY ASKS FOR — refusing those would be refusing correct work. Nobody
160
+ * asks an agent to rewrite its own permission file mid-run, and a switch to
161
+ * disable this guard would live in the very directory the guard protects, so it
162
+ * could turn itself off. `.git/` is hard-refused for the same reason.
163
+ *
164
+ * ⭐ READS ARE UNTOUCHED. Write is the dangerous verb; an agent that can read
165
+ * its own rules can explain them, which users ask for and costs nothing.
166
+ *
167
+ * ⚠️ THE SAME RULE LIVES IN `policy.mjs` AS `isPolicyProtectedPath`, because
168
+ * this module cannot import that one (policy → tools → workspace is a cycle).
169
+ * `test/agent-cannot-rewrite-its-own-leash.test.mjs` asserts the two agree on a
170
+ * table of paths — without it they drift, which is precisely how the timeout
171
+ * string and its matcher came apart for weeks.
172
+ */
173
+ const AGENT_CONFIG_DIR = '.acuvo';
174
+
175
+ /**
176
+ * Characters no Windows filesystem will store in a name. `\` and `/` can never
177
+ * actually reach the segment test — separators are unified below and the split
178
+ * consumes them — but they stay in the class so this reads as the OS rule it is
179
+ * rather than as a list someone trimmed and a later reader has to re-derive.
180
+ */
181
+ const FORBIDDEN_SEGMENT_CHARS = /[<>:"|?*\\/]/;
182
+
183
+ /**
184
+ * ⚠️ MS-DOS DEVICE NAMES, STILL RESERVED FORTY YEARS ON. `nul` and `com1.txt`
185
+ * passed the old whitelist cleanly, and both are undeletable debris once
186
+ * created: Explorer, `del` and `Remove-Item` all fail, because the OS opens the
187
+ * DEVICE rather than the file. Only a `\\?\`-prefixed incantation removes them,
188
+ * which is not knowledge anybody should need because an agent guessed a
189
+ * filename. Matched on the stem — the extension does not save you.
190
+ */
191
+ const RESERVED_DEVICE_NAMES = new Set([
192
+ 'con', 'prn', 'aux', 'nul',
193
+ ...Array.from({ length: 10 }, (_, i) => `com${i}`),
194
+ ...Array.from({ length: 10 }, (_, i) => `lpt${i}`),
195
+ ]);
196
+
197
+ /**
198
+ * ⚠️ AN ERROR STRING IS AN INSTRUCTION TO WHOEVER READS IT, AND HERE THAT IS A
199
+ * MODEL. `EPERM: operation not permitted, open 'C:\…'` reads as noise and
200
+ * invites the identical call again next round; "permission denied" is a fact it
201
+ * can route around by choosing a different file. The raw message is kept for
202
+ * everything else, because an unclassified failure the model can quote is more
203
+ * useful to a human reading the transcript than a tidy euphemism.
204
+ *
205
+ * @param {unknown} err
206
+ * @returns {string}
207
+ */
208
+ function describeFsError(err) {
209
+ const code = err && typeof err === 'object' ? /** @type {{ code?: unknown }} */ (err).code : undefined;
210
+ if (code === 'EACCES' || code === 'EPERM') return 'permission denied';
211
+ if (err instanceof Error && err.message) return err.message;
212
+ return 'unreadable';
213
+ }
214
+
215
+ /**
216
+ * ── ⚠️ THE CONTRACTS ARE DECLARED, NOT INFERRED ────────────────────────────
217
+ * This package is plain `.mjs` with no build step, but the console's TypeScript
218
+ * suite imports it (`console/lib/acuvo-code-workspace.test.ts`) and `allowJs` is
219
+ * on — so `tsc --noEmit` type-checks these modules through that import and the
220
+ * whole repo's build depends on what it infers. Inference alone widens every
221
+ * `ok: false` to `ok: boolean`, which destroys the discriminated union and makes
222
+ * `if (r.ok) r.absolute` a type error at the CALL SITE rather than here.
223
+ *
224
+ * So the shapes are stated. It costs a few JSDoc blocks and it is what makes
225
+ * "one registry, two clients" survive a type-checker: the TS client gets a real
226
+ * contract without this package acquiring a compiler.
227
+ *
228
+ * @typedef {{ ok: false, reason: string }} PathRefused
229
+ * @typedef {{ ok: true, path: string }} PathAccepted
230
+ * @typedef {{ ok: true, absolute: string, relative: string, root: string }} PathResolved
231
+ * @typedef {{ ok: false, error: string }} ToolFailure
232
+ * @typedef {{ ok: true, path: string, content: string, bytes: number }} ReadOk
233
+ * @typedef {{ ok: true, path: string, bytes: number, previousBytes: number, created: boolean, dryRun?: boolean }} WriteOk
234
+ * @typedef {{ name: string, type: 'dir' | 'file', bytes?: number, skipped?: boolean }} DirEntry
235
+ * @typedef {{ ok: true, path: string, entries: DirEntry[], truncated: boolean }} ListOk
236
+ */
237
+
238
+ /** Windows compares paths case-insensitively; a case-only mismatch must not
239
+ * read as "outside the root" and refuse a legitimate file. */
240
+ const normalizeCase = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
241
+
242
+ function isInside(root, candidate) {
243
+ const a = normalizeCase(root);
244
+ const b = normalizeCase(candidate);
245
+ return b === a || b.startsWith(a.endsWith(sep) ? a : a + sep);
246
+ }
247
+
248
+ /**
249
+ * The LEXICAL half: is this string allowed to name a file at all?
250
+ *
251
+ * Pure — no filesystem access, which is what makes it exhaustively testable
252
+ * without a temp directory. Returns the normalised POSIX-ish relative path, or
253
+ * null with a reason.
254
+ *
255
+ * @param {unknown} raw
256
+ * @returns {PathAccepted | PathRefused}
257
+ */
258
+ export function normalizeRelativePath(raw) {
259
+ if (typeof raw !== 'string') return { ok: false, reason: 'path must be a string' };
260
+ const trimmed = raw.trim();
261
+ if (!trimmed) return { ok: false, reason: 'empty path' };
262
+ if (trimmed.length > MAX_PATH_LENGTH) return { ok: false, reason: `path longer than ${MAX_PATH_LENGTH} characters` };
263
+ // ⚠️ Checked on the RAW string, before any normalisation can hide it. A NUL
264
+ // byte truncates the path in some syscalls, so `safe.txt\0../../etc` has been
265
+ // a real bypass in more than one language runtime.
266
+ // eslint-disable-next-line no-control-regex
267
+ if (/[\u0000-\u001f]/.test(trimmed)) return { ok: false, reason: 'path contains control characters' };
268
+
269
+ const unified = trimmed.replace(/\\/g, '/');
270
+ // UNC (`//server/share`) before the leading-slash test, so the reason is honest.
271
+ if (unified.startsWith('//')) return { ok: false, reason: 'UNC network path' };
272
+ if (unified.startsWith('/')) return { ok: false, reason: 'absolute path' };
273
+ if (/^[A-Za-z]:/.test(unified)) return { ok: false, reason: 'absolute path with a drive letter' };
274
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(unified)) return { ok: false, reason: 'URL, not a path' };
275
+
276
+ // `.` is a no-op segment and is dropped; `..` is the attack and is refused.
277
+ // Conflating the two cost `generated-files.ts` a test — models write `./x.css`
278
+ // constantly because that is how the href reads in the HTML they just emitted.
279
+ const segments = unified.split('/').filter((s) => s !== '' && s !== '.');
280
+ if (segments.length === 0) return { ok: false, reason: 'path resolves to the workspace root itself' };
281
+ if (segments.length > MAX_DEPTH) return { ok: false, reason: `path deeper than ${MAX_DEPTH} directories` };
282
+ for (const s of segments) {
283
+ if (s === '..') return { ok: false, reason: 'path escapes the workspace with ".."' };
284
+ /**
285
+ * ── THE DENYLIST ────────────────────────────────────────────────────────
286
+ * ⚠️ This is NOT the containment check — see the header. Escaping is refused
287
+ * by the `..` test above and by `resolveInWorkspace` below, both of which
288
+ * are structural and neither of which cares what characters a name uses.
289
+ * What is refused here is names the filesystem cannot hold. Everything else
290
+ * — spaces, `[ ] ( ) @ + , # ! & ' ~ $ =`, Unicode letters — is a real
291
+ * filename in a real repository and is permitted.
292
+ *
293
+ * ⭐ Each refusal names the offending character or the rule, because the
294
+ * reader is a model choosing what to do next and "unsupported characters"
295
+ * told it nothing it could act on.
296
+ */
297
+ const bad = FORBIDDEN_SEGMENT_CHARS.exec(s);
298
+ if (bad) return { ok: false, reason: `path segment "${s}" contains a character Windows cannot store: "${bad[0]}"` };
299
+ // `...`, `....` — the `..` family beyond the two everyone remembers. Windows
300
+ // creates them through the API and then no ordinary tool can remove them.
301
+ if (/^\.+$/.test(s)) {
302
+ return { ok: false, reason: `path segment "${s}" is nothing but dots — Windows will create it and then refuse to delete it. Give the file a name.` };
303
+ }
304
+ if (s.endsWith('.') || s.endsWith(' ')) {
305
+ const what = s.endsWith('.') ? 'a dot' : 'a space';
306
+ return { ok: false, reason: `path segment "${s}" ends in ${what} — Windows silently strips it, so the file written is not the file named, and the result cannot be deleted normally. Drop the trailing character.` };
307
+ }
308
+ const dot = s.indexOf('.');
309
+ const stem = dot === -1 ? s : s.slice(0, dot);
310
+ if (RESERVED_DEVICE_NAMES.has(stem.toLowerCase())) {
311
+ return { ok: false, reason: `path segment "${s}" starts with the reserved device name "${stem}" — Windows opens the device instead of a file. Rename it, e.g. "${stem}-notes${dot === -1 ? '' : s.slice(dot)}".` };
312
+ }
313
+ }
314
+ return { ok: true, path: segments.join('/') };
315
+ }
316
+
317
+ /**
318
+ * The FILESYSTEM half: turn a model-supplied path into an absolute path proven
319
+ * to live inside the workspace, symlinks included.
320
+ *
321
+ * `intent` is 'read' or 'write' — only the second consults WRITE_FORBIDDEN_ROOTS.
322
+ *
323
+ * @param {string} root
324
+ * @param {unknown} raw
325
+ * @param {'read' | 'write'} [intent]
326
+ * @returns {PathResolved | PathRefused}
327
+ */
328
+ export function resolveInWorkspace(root, raw, intent = 'read') {
329
+ const lexical = normalizeRelativePath(raw);
330
+ if (!lexical.ok) return lexical;
331
+
332
+ const segments = lexical.path.split('/');
333
+ if (intent === 'write') {
334
+ // ⚠️ The LAST segment is excluded: a file literally named `node_modules`
335
+ // is not a directory anybody executes out of, and refusing it would be a
336
+ // refusal of correct work for a name collision.
337
+ const blocked = segments.slice(0, -1).find((seg) => WRITE_FORBIDDEN_ROOTS.has(seg));
338
+ if (blocked) {
339
+ const nested = segments.indexOf(blocked) > 0 ? ` (nested at ${segments.slice(0, segments.indexOf(blocked) + 1).join('/')}/)` : '';
340
+ return { ok: false, reason: `writing into ${blocked}/${nested} is refused — it executes code on the owner's next command, and no diff would show it` };
341
+ }
342
+ /**
343
+ * ⚠️⚠️ THE `.acuvo/` GUARD IS DELIBERATELY *NOT* HERE, and putting it here
344
+ * was the first attempt. This function is a PATH UTILITY that the package's
345
+ * own internals use — `acceptance.mjs:323` resolves `.acuvo/acceptance.json`
346
+ * through it with intent 'write' and then writes with raw `fs`. A refusal at
347
+ * this layer broke seven tests of legitimate machinery: the product writing
348
+ * its own state is not the threat.
349
+ *
350
+ * ⭐ The threat is the MODEL writing there, and the model only ever arrives
351
+ * through `createLocalExecutor` — so the guard lives on those methods. See
352
+ * `agentWriteRefusal` below.
353
+ */
354
+ }
355
+
356
+ // realpath the ROOT once, so a workspace that is itself reached through a
357
+ // symlink (macOS /tmp, a junction on Windows) does not make every child look
358
+ // like an escape.
359
+ let realRoot;
360
+ try {
361
+ realRoot = realpathSync(resolve(root));
362
+ } catch {
363
+ return { ok: false, reason: `workspace directory does not exist: ${root}` };
364
+ }
365
+
366
+ const absolute = resolve(realRoot, ...segments);
367
+ // Belt and braces: the whitelist already makes this unreachable, which is
368
+ // exactly why it is cheap to keep. It is the assertion that the lexical layer
369
+ // did its job, and it is what would catch a future loosening of the regex.
370
+ if (!isInside(realRoot, absolute)) {
371
+ return { ok: false, reason: 'resolved outside the workspace' };
372
+ }
373
+
374
+ // ── THE SYMLINK CHECK ─────────────────────────────────────────────────────
375
+ // Walk up to the deepest ancestor that EXISTS and realpath that. Resolving it
376
+ // resolves every link along its whole path in one call, and the non-existent
377
+ // tail cannot be a link because it is not anything yet.
378
+ let existing = absolute;
379
+ while (!existsSync(existing)) {
380
+ const parent = dirname(existing);
381
+ if (parent === existing) break; // reached a filesystem root; cannot happen inside a workspace
382
+ existing = parent;
383
+ }
384
+ let realExisting;
385
+ try {
386
+ realExisting = realpathSync(existing);
387
+ } catch {
388
+ return { ok: false, reason: 'path could not be resolved on disk' };
389
+ }
390
+ if (!isInside(realRoot, realExisting)) {
391
+ return { ok: false, reason: 'path escapes the workspace through a symlink' };
392
+ }
393
+
394
+ return { ok: true, absolute, relative: lexical.path, root: realRoot };
395
+ }
396
+
397
+ /**
398
+ * The executor the agent turn is handed. Everything it can do to a filesystem
399
+ * is these three functions, and all three go through `resolveInWorkspace`.
400
+ *
401
+ * Returns plain data (never throws for an expected failure) because the result
402
+ * of a tool call is something the model has to be TOLD about, not something that
403
+ * should kill the process.
404
+ */
405
+ /**
406
+ * ── ⚠️⚠️ THE AGENT MAY NOT REWRITE ITS OWN LEASH ────────────────────────────
407
+ *
408
+ * `.acuvo/` holds `mcp.json` (which NAMES THE PROGRAMS WE SPAWN),
409
+ * `commands.json` (which grants language ecosystems) and `policy.json` (round
410
+ * and dollar ceilings). Proven against the real executor on 2026-08-13:
411
+ * `write_file('.acuvo/mcp.json', …)` succeeded and the next run would have
412
+ * spawned the binary it named — the same sentence `.git/` is refused for, aimed
413
+ * at our own permission file.
414
+ *
415
+ * ⚠️ THE GUARD IS ON THE EXECUTOR, NOT ON `resolveInWorkspace`. Putting it there
416
+ * was the first attempt and it broke seven tests: the package's own internals
417
+ * (`acceptance.mjs`) legitimately write inside `.acuvo/`. The product writing its
418
+ * own state is not the threat; the MODEL writing there is, and the model only
419
+ * ever arrives through this executor.
420
+ *
421
+ * ⚠️ NOT MERELY A PROMPT RULE. The system prompt tells the model not to enable a
422
+ * preset for itself. That is worth saying and it is not a boundary.
423
+ *
424
+ * ⚠️ HARD-REFUSED RATHER THAN A POLICY SETTING, unlike the note on
425
+ * WRITE_FORBIDDEN_ROOTS: that argument is about `.github/workflows`, which a
426
+ * user legitimately asks for. Nobody asks an agent to rewrite its own
427
+ * permission file mid-run — and a switch to disable this would live in the very
428
+ * directory it protects, so it could turn itself off.
429
+ *
430
+ * ⭐ READS ARE UNTOUCHED. An agent that can read its own rules can explain them.
431
+ *
432
+ * @returns {string|null} a refusal sentence, or null when the path is fine
433
+ */
434
+ function agentWriteRefusal(relPath) {
435
+ const segments = String(relPath ?? '').replace(/\\/g, '/').split('/').filter(Boolean);
436
+ if (segments.length < 2 || segments[0] !== AGENT_CONFIG_DIR) return null;
437
+ return `writing into ${AGENT_CONFIG_DIR}/ is refused — that directory decides which programs this agent may `
438
+ + 'spawn (mcp.json), which languages it may run (commands.json) and its own round and dollar ceilings '
439
+ + '(policy.json), so a write there grants permissions rather than doing the task. If one of them really '
440
+ + 'should change, say which line and why, and let the owner edit it themselves.';
441
+ }
442
+
443
+ /**
444
+ * ── ⭐ `claimPath` — THE ONE SEAM THAT MAKES LEASES A GUARANTEE ─────────────
445
+ *
446
+ * Injected rather than imported, for the reason every other disk touch in this
447
+ * file is injected: `workspace.mjs` is the lowest layer here and must stay
448
+ * testable with no filesystem and no lease directory. `bin/` owns the policy
449
+ * and builds the claimer (`lib/auto-lease.mjs`); this file only asks.
450
+ *
451
+ * ⚠️ `null` BY DEFAULT, so every existing caller and every existing test is
452
+ * byte-identical. A guard on the write path is the last place to change
453
+ * behaviour for someone who did not ask.
454
+ */
455
+ /**
456
+ * ── ⭐⭐ `journal` — THE SECOND SEAM ON THIS PATH, AND WHY IT IS THE ONLY ONE
457
+ * CHECKPOINTING NEEDS ──────────────────────────────────────────────────
458
+ *
459
+ * `writeFile` and `deleteFile` below are the ONLY two ways a file on disk
460
+ * changes through this agent — `write_files`, `edit_file` and the media verbs
461
+ * all call them (lib/write-many.mjs:22-24 states it outright). So an undo does
462
+ * not need a hook per tool; it needs the previous bytes read at these two
463
+ * points, which is what `journal.record` does.
464
+ *
465
+ * ⚠️ INJECTED, NOT IMPORTED, for exactly the reason `claimPath` is: this file
466
+ * is the lowest layer and must stay testable with no filesystem and no journal.
467
+ * `bin/` owns the policy and builds it (lib/checkpoint.mjs); this file only
468
+ * tells it what is about to happen.
469
+ *
470
+ * ⚠️ `null` BY DEFAULT, so every existing caller and every existing test is
471
+ * byte-identical.
472
+ */
473
+ export function createLocalExecutor(root, { dryRun = false, claimPath = null, holder = null, journal = null } = {}) {
474
+ /**
475
+ * ⚠️ A WRITE MUST NEVER DIE BECAUSE BOOKKEEPING DID — the same rule
476
+ * `audit.mjs` states in its header. `openJournal`'s `record` is written never
477
+ * to throw and to collect its own failures in `errors`; this catch exists for
478
+ * the OTHER implementations (a stub in a test, a future one) so a bug in the
479
+ * recorder can never cost the user the work. It still lands in `errors` when
480
+ * the journal has one, because a silent checkpoint failure is the thing that
481
+ * makes an undo a lie.
482
+ */
483
+ const note = (mutation) => {
484
+ if (!journal) return;
485
+ try {
486
+ journal.record(mutation);
487
+ } catch (err) {
488
+ if (Array.isArray(journal.errors)) {
489
+ journal.errors.push(`checkpoint: ${mutation.path}: ${err instanceof Error ? err.message : String(err)}`);
490
+ }
491
+ }
492
+ };
493
+
494
+ const realRoot = realpathSync(resolve(root));
495
+
496
+ return {
497
+ root: realRoot,
498
+ dryRun,
499
+ /**
500
+ * ⭐ WHO THIS TERMINAL IS, when the user said so. Carried here because the
501
+ * executor is what every tool already receives — the alternative was
502
+ * threading a holder through the dispatcher as a second parameter beside
503
+ * `root`, which is the same fact in two places waiting to disagree.
504
+ *
505
+ * `null` for a terminal that named no holder, which is the single-terminal
506
+ * case and must keep behaving exactly as it does today.
507
+ */
508
+ holder,
509
+
510
+ /** @param {unknown} path @returns {ReadOk | ToolFailure} */
511
+ /**
512
+ * ⚠️ ADDED SO THE DISPATCHER NEVER BRANCHES ON EXECUTOR TYPE. delete.mjs
513
+ * owns the schema and the refusal wording; the executor owns HOW a file
514
+ * stops existing — on disk here, in a Map for the browser builder. Two
515
+ * implementations of one verb is fine; two dispatchers is not.
516
+ */
517
+ deleteFile(path) {
518
+ // ⚠️ Deleting mcp.json is not safer than rewriting it — it silently drops
519
+ // the servers a user configured, which is a change to what runs.
520
+ const leash = agentWriteRefusal(path);
521
+ if (leash) return { ok: false, error: leash };
522
+ const r = resolveInWorkspace(realRoot, path, 'write');
523
+ if (!r.ok) return { ok: false, error: r.reason };
524
+ /**
525
+ * ⚠️ AFTER the path is resolved, so the claim is on the REAL relative
526
+ * path rather than whatever spelling the model used — `./src/app.ts` and
527
+ * `src/app.ts` must be one lease, not two. And before anything is
528
+ * removed, obviously: a refusal has to arrive while the file still exists.
529
+ */
530
+ if (claimPath) {
531
+ const claim = claimPath(r.relative);
532
+ if (!claim.ok) return { ok: false, error: claim.error };
533
+ }
534
+ let stat;
535
+ try { stat = statSync(r.absolute); } catch (err) {
536
+ // ⚠️ "no such file" for an EPERM is a lie, and the model acts on it by
537
+ // creating the file it was told is missing. Branch on the code.
538
+ const code = err && typeof err === 'object' ? /** @type {{ code?: unknown }} */ (err).code : undefined;
539
+ if (code === 'EACCES' || code === 'EPERM') {
540
+ return { ok: false, error: `could not inspect ${r.relative}: permission denied — it exists but this account cannot see it. Nothing was deleted.` };
541
+ }
542
+ return { ok: false, error: `no such file: ${r.relative} — nothing was deleted` };
543
+ }
544
+ if (stat.isDirectory()) {
545
+ return { ok: false, error: `${r.relative} is a directory. This agent deletes one FILE at a time and never a directory — removing a tree is the operation nobody can review.` };
546
+ }
547
+ if (!dryRun) {
548
+ // ⭐ BEFORE the unlink, obviously — after it there is nothing left to
549
+ // copy. A delete is the mutation an undo matters most for: a rewritten
550
+ // file is still on disk to look at, a deleted one is gone.
551
+ note({ verb: 'delete', path: r.relative, absolute: r.absolute, after: null });
552
+ try { unlinkSync(r.absolute); } catch (err) {
553
+ return { ok: false, error: `could not delete ${r.relative}: ${err instanceof Error ? err.message : String(err)}` };
554
+ }
555
+ }
556
+ return { ok: true, path: r.relative, bytes: stat.size, dryRun };
557
+ },
558
+
559
+ readFile(path) {
560
+ const r = resolveInWorkspace(realRoot, path, 'read');
561
+ if (!r.ok) return { ok: false, error: r.reason };
562
+ let stat;
563
+ try {
564
+ stat = statSync(r.absolute);
565
+ } catch (err) {
566
+ const code = err && typeof err === 'object' ? /** @type {{ code?: unknown }} */ (err).code : undefined;
567
+ if (code === 'EACCES' || code === 'EPERM') {
568
+ return { ok: false, error: `could not read ${r.relative}: permission denied` };
569
+ }
570
+ return { ok: false, error: `no such file: ${r.relative}` };
571
+ }
572
+ if (stat.isDirectory()) return { ok: false, error: `${r.relative} is a directory — use list_dir` };
573
+ if (stat.size > MAX_READ_BYTES) {
574
+ return { ok: false, error: `${r.relative} is ${stat.size} bytes, over the ${MAX_READ_BYTES}-byte read limit` };
575
+ }
576
+ // ⚠️ 'utf8' on a binary file yields replacement characters rather than an
577
+ // error, so the model would silently reason about garbage. Detecting a NUL
578
+ // in the first block is the cheap, standard heuristic and it is honest
579
+ // about what it cannot read.
580
+ /**
581
+ * ── ⚠️ THE READ THAT KILLED WHOLE SESSIONS ──────────────────────────────
582
+ * This was the ONE unguarded call in the file — the `statSync` above was
583
+ * wrapped and this was not, which is exactly the asymmetry nobody notices
584
+ * in review. `gatherWorkspaceContext` (turn.mjs) pre-reads every small
585
+ * file in the top two directory levels BEFORE the first model call, so a
586
+ * single permission-denied file sitting in the workspace root took the
587
+ * entire run down with a raw EPERM stack — before a token was spent, with
588
+ * nothing in the output the owner could act on.
589
+ *
590
+ * ⭐ And `statSync` succeeding proves nothing: on Windows a deny ACE lets
591
+ * you stat a file you cannot open. Existence and readability are two
592
+ * different questions and only one of them was being asked.
593
+ */
594
+ /**
595
+ * ── ⚠️⚠️ READ THE BYTES, NOT A DECODED STRING. THE DECODE IS THE LOSS. ──
596
+ *
597
+ * This was `readFileSync(r.absolute, 'utf8')`, and that one argument was a
598
+ * silent data-destruction bug. `'utf8'` NEVER FAILS: every byte it cannot
599
+ * make sense of becomes U+FFFD, and a write-back turns each one into
600
+ * `ef bf bd`. So a cp1252 / latin-1 / Shift-JIS file, which is legitimate
601
+ * text and merely not our encoding, came back permanently mangled with
602
+ * `ok: true` and a plausible byte count sitting on top of it.
603
+ *
604
+ * ⚠️ `lib/edit.mjs:85` ALREADY FIXED THIS AND THE CLI DID NOT CALL IT.
605
+ * `editFile()` carries the whole defence, but `tools.mjs` dispatches
606
+ * `edit_file` to `editThroughExecutor()`, which reads through THIS
607
+ * function. The fix was written, argued, tested, and routed around. The
608
+ * guard belongs here rather than in a second copy inside edit.mjs because
609
+ * `read_file`, `edit_file` and `gatherWorkspaceContext`'s automatic
610
+ * pre-read of the workspace all come through this one door.
611
+ *
612
+ * ⭐ THE PRE-READ IS WHY THIS OUTRANKS AN EDIT BUG. The gather reads small
613
+ * files before round 1, so one cp1252 file in the workspace root fed the
614
+ * model text that was not the text on disk, before a token was spent, and
615
+ * with no way for the model to know.
616
+ */
617
+ let raw;
618
+ try {
619
+ raw = readFileSync(r.absolute);
620
+ } catch (err) {
621
+ return { ok: false, error: `could not read ${r.relative}: ${describeFsError(err)}` };
622
+ }
623
+
624
+ /**
625
+ * ⭐ `fatal: true` IS THE FIX: it throws on exactly the bytes `'utf8'`
626
+ * would have silently replaced.
627
+ *
628
+ * ⚠️ `ignoreBOM: true` IS LOAD-BEARING AND IS NAMED BACKWARDS. It means
629
+ * "do not treat a leading U+FEFF as a marker to swallow", i.e. KEEP the
630
+ * BOM as an ordinary character. The default (`false`) strips it, which
631
+ * quietly deletes three bytes from the front of every BOM'd file on every
632
+ * read-then-edit round trip, and BOM'd UTF-8 is what many Windows editors
633
+ * write by default.
634
+ *
635
+ * ⚠️ The binary check below still runs on the DECODED string so that its
636
+ * message wins for a .png: "looks binary" is more useful than "not valid
637
+ * UTF-8", and a real binary is almost always both. A NUL byte always
638
+ * decodes cleanly to U+0000, so a binary file reaches that check intact.
639
+ */
640
+ let content;
641
+ try {
642
+ content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw);
643
+ } catch {
644
+ if (raw.includes(0)) return { ok: false, error: `${r.relative} looks binary, refusing to read it as text` };
645
+ return {
646
+ ok: false,
647
+ error: `${r.relative} is not valid UTF-8, so it will not be read as text. `
648
+ + 'Decoding it would replace every undecodable byte with U+FFFD, and writing that back would destroy those bytes permanently.',
649
+ };
650
+ }
651
+ if (content.includes('\u0000')) return { ok: false, error: `${r.relative} looks binary — refusing to read it as text` };
652
+ return { ok: true, path: r.relative, content, bytes: stat.size };
653
+ },
654
+
655
+ /** @param {unknown} path @param {unknown} content @returns {WriteOk | ToolFailure} */
656
+ writeFile(path, content) {
657
+ // ⚠️ FIRST, before the size and path checks — the reason this path is
658
+ // refused has nothing to do with how big or well-formed the content is.
659
+ const leash = agentWriteRefusal(path);
660
+ if (leash) return { ok: false, error: leash };
661
+ if (typeof content !== 'string') return { ok: false, error: 'content must be a string' };
662
+ const bytes = Buffer.byteLength(content, 'utf8');
663
+ if (bytes > MAX_WRITE_BYTES) {
664
+ return { ok: false, error: `refusing to write ${bytes} bytes (limit ${MAX_WRITE_BYTES})` };
665
+ }
666
+ const r = resolveInWorkspace(realRoot, path, 'write');
667
+ if (!r.ok) return { ok: false, error: r.reason };
668
+ /**
669
+ * ⭐ THE CLAIM, ON THE RESOLVED RELATIVE PATH. Two spellings of one file
670
+ * must be one lease — `lease.mjs` normalises too, but claiming the
671
+ * resolved path means the two layers cannot disagree about what was
672
+ * claimed. Placed before the existence check so a refusal costs no I/O.
673
+ */
674
+ if (claimPath) {
675
+ const claim = claimPath(r.relative);
676
+ if (!claim.ok) return { ok: false, error: claim.error };
677
+ }
678
+
679
+ // Whether this CREATES or REPLACES is the single most important fact in
680
+ // the summary, and it can only be known before the write.
681
+ const existed = existsSync(r.absolute);
682
+ let previousBytes = 0;
683
+ if (existed) {
684
+ // Unguarded until 2026-08-10, for the same reason the read below it was:
685
+ // `existsSync` had just said yes, so the throw looked impossible. It is
686
+ // not — a deny ACE, or the file vanishing between the two calls.
687
+ let stat;
688
+ try {
689
+ stat = statSync(r.absolute);
690
+ } catch (err) {
691
+ return { ok: false, error: `could not inspect ${r.relative} before writing: ${describeFsError(err)}` };
692
+ }
693
+ if (stat.isDirectory()) return { ok: false, error: `${r.relative} is a directory` };
694
+ previousBytes = stat.size;
695
+ }
696
+ /**
697
+ * ⚠️ DRY RUN STOPS HERE, NOT EARLIER. Every safety check above has already
698
+ * run, so `--dry-run` reports exactly the refusals a real run would — a
699
+ * preview that skipped validation would be a preview of a different
700
+ * command, which is the only way a dry run can lie.
701
+ */
702
+ if (dryRun) return { ok: true, path: r.relative, bytes, previousBytes, created: !existed, dryRun: true };
703
+ /**
704
+ * ⭐ AFTER the dry-run return, so a preview records nothing — a dry run
705
+ * that filled the journal would be a run that "touched nothing" and left
706
+ * two files behind. And before the write, because the previous contents
707
+ * only exist until the line below.
708
+ */
709
+ note({ verb: 'write', path: r.relative, absolute: r.absolute, after: content });
710
+ try {
711
+ mkdirSync(dirname(r.absolute), { recursive: true });
712
+ writeFileSync(r.absolute, content, 'utf8');
713
+ } catch (err) {
714
+ return { ok: false, error: `write failed: ${err instanceof Error ? err.message : String(err)}` };
715
+ }
716
+ return { ok: true, path: r.relative, bytes, previousBytes, created: !existed };
717
+ },
718
+
719
+ /**
720
+ * ── ⭐⭐ RENAMING WAS IMPOSSIBLE, NOT MERELY EXPENSIVE ────────────────────
721
+ *
722
+ * With no move verb, the only way to rename was `read_file` + `write_file`
723
+ * + `delete_file`: three rounds of a five-round default, and the file's
724
+ * whole content through the model's context TWICE. MEASURED against the
725
+ * real executor, and for two very ordinary files it does not work at all:
726
+ *
727
+ * a 250KB source file → `read_file` refuses: "over the 200000-byte read
728
+ * limit". There is no second way in.
729
+ * `logo.png` → `read_file` refuses: binary. Which is the good
730
+ * outcome — the alternative is a silent corruption
731
+ * on the way back out.
732
+ *
733
+ * So today an agent cannot rename a large module or move an image into
734
+ * `assets/`, and nothing tells it why except a read error about a file it
735
+ * never wanted to read.
736
+ *
737
+ * ⚠️⚠️ AND THE OBVIOUS IMPLEMENTATION LAUNDERS CREDENTIALS. `git.mjs`
738
+ * refuses to COMMIT `.env`, `id_rsa`, `*.pem` and friends BY PATH — so
739
+ * `move_file('.env', 'notes/env.txt')` followed by `git_commit` puts the
740
+ * secret in history with every check passing, because the name it is
741
+ * checked under is one the agent chose. Verified against the real
742
+ * `refusedCommitPath`: `.env` REFUSED, `notes/env.txt` allowed.
743
+ *
744
+ * ⭐ The rule is precise rather than blunt: a move is refused when it
745
+ * carries a path OUT of the protected namespace. `.env` → `.env.bak` is
746
+ * fine (still refused at commit); `.env` → `notes/env.txt` is not. Blanket
747
+ * refusal would block renaming `.env.example`, which is an ordinary thing
748
+ * to do and would be a guard that fails correct work.
749
+ *
750
+ * ⚠️ DIRECTORIES ARE REFUSED, and the reason is the checkpoint rather than
751
+ * squeamishness. The journal snapshots ONE path per entry, so a directory
752
+ * move would be recorded as a single mutation covering an unknown number of
753
+ * files and `acuvo rewind` would silently restore none of them. An undo
754
+ * that lies is worse than a verb that is missing.
755
+ *
756
+ * @param {unknown} from
757
+ * @param {unknown} to
758
+ * @param {{ overwrite?: boolean }} [opts]
759
+ */
760
+ moveFile(from, to, { overwrite = false } = {}) {
761
+ // ⚠️ BOTH SIDES. A path this agent may not write is one it may not create
762
+ // by moving onto, nor destroy by moving away from.
763
+ for (const p of [from, to]) {
764
+ const leash = agentWriteRefusal(p);
765
+ if (leash) return { ok: false, error: leash };
766
+ }
767
+ const src = resolveInWorkspace(realRoot, from, 'write');
768
+ if (!src.ok) return { ok: false, error: src.reason };
769
+ const dst = resolveInWorkspace(realRoot, to, 'write');
770
+ if (!dst.ok) return { ok: false, error: dst.reason };
771
+ if (src.relative === dst.relative) {
772
+ return { ok: false, error: `${src.relative} and ${dst.relative} are the same file — nothing to move` };
773
+ }
774
+
775
+ /**
776
+ * ⚠️ ON THE RESOLVED PATHS, so `./.env` and `.env` cannot disagree, and
777
+ * the check reads the name the file will actually be committed under.
778
+ */
779
+ if (refusedCommitPath(src.relative) && !refusedCommitPath(dst.relative)) {
780
+ return {
781
+ ok: false,
782
+ error: `refusing to move ${src.relative} to ${dst.relative}: the source is a credential path this agent will never commit, `
783
+ + 'and the destination is not — so the move would make it committable under a name of the agent\'s choosing. '
784
+ + 'Rename it yourself if that is really what you want.',
785
+ };
786
+ }
787
+
788
+ if (claimPath) {
789
+ for (const rel of [src.relative, dst.relative]) {
790
+ const claim = claimPath(rel);
791
+ if (!claim.ok) return { ok: false, error: claim.error };
792
+ }
793
+ }
794
+
795
+ let stat;
796
+ try { stat = statSync(src.absolute); } catch (err) {
797
+ const code = err && typeof err === 'object' ? /** @type {{ code?: unknown }} */ (err).code : undefined;
798
+ if (code === 'EACCES' || code === 'EPERM') {
799
+ return { ok: false, error: `could not inspect ${src.relative}: permission denied. Nothing was moved.` };
800
+ }
801
+ return { ok: false, error: `no such file: ${src.relative} — nothing was moved` };
802
+ }
803
+ if (stat.isDirectory()) {
804
+ return {
805
+ ok: false,
806
+ error: `${src.relative} is a directory. This agent moves one FILE at a time: the checkpoint journal records one path per `
807
+ + 'mutation, so a directory move would look like a single change and `acuvo rewind` would restore none of it. '
808
+ + 'Move the files individually, or do the directory yourself with `git mv`.',
809
+ };
810
+ }
811
+
812
+ const destExisted = existsSync(dst.absolute);
813
+ if (destExisted && !overwrite) {
814
+ return {
815
+ ok: false,
816
+ error: `${dst.relative} already exists. Pass overwrite: true if replacing it is what you mean — a move that silently `
817
+ + 'overwrote a file would destroy work nobody asked about.',
818
+ };
819
+ }
820
+ if (destExisted) {
821
+ let dstat;
822
+ try { dstat = statSync(dst.absolute); } catch (err) {
823
+ return { ok: false, error: `could not inspect ${dst.relative} before overwriting it: ${describeFsError(err)}` };
824
+ }
825
+ if (dstat.isDirectory()) return { ok: false, error: `${dst.relative} is a directory` };
826
+ }
827
+
828
+ // ⚠️ Same placement as writeFile: after every check, before any I/O. A
829
+ // preview that skipped validation would be a preview of a different
830
+ // command.
831
+ if (dryRun) {
832
+ return { ok: true, from: src.relative, to: dst.relative, bytes: stat.size, replaced: destExisted, dryRun: true };
833
+ }
834
+
835
+ /**
836
+ * ⭐ TWO ENTRIES, BOTH BEFORE THE MOVE, AND THAT IS A COMPLETE UNDO.
837
+ * `delete` on the source snapshots its bytes — the journal reads the file
838
+ * as a BUFFER, so this works for the binary and large files that are the
839
+ * whole reason this verb exists. `write` on the destination records that
840
+ * it did not exist, so a rewind removes it. Recorded before, because
841
+ * afterwards the source is gone.
842
+ */
843
+ note({ verb: 'delete', path: src.relative, absolute: src.absolute, after: null });
844
+ note({ verb: 'write', path: dst.relative, absolute: dst.absolute, after: null });
845
+
846
+ try {
847
+ mkdirSync(dirname(dst.absolute), { recursive: true });
848
+ renameSync(src.absolute, dst.absolute);
849
+ } catch (err) {
850
+ const code = err && typeof err === 'object' ? /** @type {{ code?: unknown }} */ (err).code : undefined;
851
+ /**
852
+ * ⚠️ EXDEV IS NOT HYPOTHETICAL INSIDE ONE WORKSPACE. A subdirectory can
853
+ * be a mount point or a junction on another volume, and `rename` across
854
+ * devices fails outright. Copy-then-unlink is the standard fallback and
855
+ * it is ordered so a failed copy leaves the source untouched.
856
+ */
857
+ if (code === 'EXDEV') {
858
+ try {
859
+ copyFileSync(src.absolute, dst.absolute);
860
+ unlinkSync(src.absolute);
861
+ } catch (err2) {
862
+ return { ok: false, error: `could not move ${src.relative} to ${dst.relative} across devices: ${describeFsError(err2)}` };
863
+ }
864
+ return { ok: true, from: src.relative, to: dst.relative, bytes: stat.size, replaced: destExisted };
865
+ }
866
+ return { ok: false, error: `could not move ${src.relative} to ${dst.relative}: ${describeFsError(err)}` };
867
+ }
868
+ return { ok: true, from: src.relative, to: dst.relative, bytes: stat.size, replaced: destExisted };
869
+ },
870
+
871
+ /** @param {unknown} [path] @returns {ListOk | ToolFailure} */
872
+ listDir(path = '.') {
873
+ // '.' is the workspace root, and `normalizeRelativePath` deliberately
874
+ // refuses the empty segment list — so the root is special-cased HERE
875
+ // rather than by weakening the rule that a path must name something.
876
+ const wantsRoot = typeof path !== 'string' || path.trim() === '' || path.trim() === '.' || path.trim() === './';
877
+ let absolute = realRoot;
878
+ let relative = '.';
879
+ if (!wantsRoot) {
880
+ const r = resolveInWorkspace(realRoot, path, 'read');
881
+ if (!r.ok) return { ok: false, error: r.reason };
882
+ absolute = r.absolute;
883
+ relative = r.relative;
884
+ }
885
+ let names;
886
+ try {
887
+ names = readdirSync(absolute);
888
+ } catch (err) {
889
+ /**
890
+ * ⚠️ THIS CATCH USED TO SAY "no such directory" FOR EVERY FAILURE,
891
+ * including a directory that demonstrably exists and that the account
892
+ * simply may not read, and including a path that is a FILE.
893
+ *
894
+ * ⭐ A model told a path does not exist does not investigate — it
895
+ * invents a plausible name and writes there instead. So the lie is the
896
+ * defect and the missing branch is only its cause; three different
897
+ * facts were being reported as one, and only one of them was true.
898
+ */
899
+ const code = err && typeof err === 'object' ? /** @type {{ code?: unknown }} */ (err).code : undefined;
900
+ if (code === 'EACCES' || code === 'EPERM') {
901
+ return { ok: false, error: `could not list ${relative}: permission denied — the directory exists but this account cannot read it. List its parent instead, or work somewhere else in the tree.` };
902
+ }
903
+ if (code === 'ENOTDIR') {
904
+ return { ok: false, error: `${relative} is a file, not a directory — use read_file` };
905
+ }
906
+ return { ok: false, error: `no such directory: ${relative}` };
907
+ }
908
+ const entries = [];
909
+ for (const name of names.sort()) {
910
+ if (entries.length >= MAX_LIST_ENTRIES) break;
911
+ // Noise the model should never spend context on. Not a safety rule —
912
+ // `.git` is still READABLE by name if it is genuinely asked for.
913
+ if (name === 'node_modules' || name === '.git' || name === '.next') {
914
+ entries.push({ name, type: 'dir', skipped: true });
915
+ continue;
916
+ }
917
+ let stat;
918
+ try {
919
+ stat = statSync(join(absolute, name));
920
+ } catch {
921
+ continue; // a broken symlink or a file that vanished mid-listing
922
+ }
923
+ entries.push({ name, type: stat.isDirectory() ? 'dir' : 'file', bytes: stat.isDirectory() ? undefined : stat.size });
924
+ }
925
+ return { ok: true, path: relative, entries, truncated: names.length > MAX_LIST_ENTRIES };
926
+ },
927
+ };
928
+ }