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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- 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
|
+
}
|