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
package/lib/policy.mjs ADDED
@@ -0,0 +1,783 @@
1
+ /**
2
+ * ── ⭐⭐ POLICY — WHAT AN ORGANISATION IS ALLOWED TO SAY "NO" TO ──────────────
3
+ *
4
+ * Everything in this package so far is a limit *we* chose: `command.mjs` picks
5
+ * the allowlisted binaries, `tools.mjs` picks which verbs a round may offer,
6
+ * `cli-args.mjs` picks the round ceiling. All of them are excellent and none of
7
+ * them are an admin's. A security reviewer's first question about a coding agent
8
+ * is not "is it safe?", it is "**can I make it safe on my terms, and can I read
9
+ * the rule I set?**" — and `ENTERPRISE.md:179` records the honest answer today:
10
+ * no org policy at all.
11
+ *
12
+ * This file is that rule. Six things an org can say:
13
+ *
14
+ * forbidTools a named verb never runs (`run_command`, `delete_file`, …)
15
+ * maxRounds a ceiling on paid completions
16
+ * maxCostUsd a ceiling on dollars per run
17
+ * requireDryRun nothing may ever touch disk
18
+ * allowModels only these model ids may be called
19
+ * allowMcp:false no MCP server is spawned, ever
20
+ *
21
+ * ── ⚠️⚠️ THE PROBLEM THAT DEFINES THE DESIGN ────────────────────────────────
22
+ *
23
+ * **The policy file lives in the workspace, and the agent can write to the
24
+ * workspace.** That is not a hypothetical: `WRITE_FORBIDDEN_ROOTS`
25
+ * (`workspace.mjs:69`) is `.git`, `node_modules`, `.next`, `.vercel` — `.acuvo/`
26
+ * is not in it, so `write_file(".acuvo/policy.json", "{}")` succeeds today. A
27
+ * control the controlled party can edit is not a control.
28
+ *
29
+ * The obvious answers are all bad:
30
+ *
31
+ * · *Detect tampering* — a hash check is a race. The agent edits the file, the
32
+ * next process reads the edited file and hashes the edited file.
33
+ * · *Make the file read-only* — a chmod the agent's own `npm run` script can
34
+ * undo, and on Windows barely a speed bump.
35
+ * · *Sign it* — a real answer, but it needs a key, and a key on the same
36
+ * machine as the agent is a key the agent can read.
37
+ *
38
+ * ⭐⭐ **SO WE DO NOT TRY TO STOP THE EDIT. WE MAKE THE EDIT USELESS.**
39
+ *
40
+ * Every field in a policy is merged by taking the STRICTER of the two values —
41
+ * forbid-lists union, allow-lists intersect, numbers take the minimum, booleans
42
+ * take the safer side. A layer can therefore only ever REMOVE permission. There
43
+ * is no value the agent can write into `.acuvo/policy.json` that grants it
44
+ * anything it did not already have. The worst it can do by writing that file is
45
+ * restrict itself further, which is a denial of service against itself and not a
46
+ * security event.
47
+ *
48
+ * That turns an unsolvable authenticity problem into a solved algebraic one:
49
+ * `merge` is a meet on a lattice — commutative, idempotent, monotone downward —
50
+ * so **"which file wins?" is not a question this system can be asked.** The
51
+ * strictest statement anywhere wins, whoever wrote it.
52
+ *
53
+ * ── ⚠️ WHAT THAT STILL DOES NOT BUY, STATED PLAINLY ─────────────────────────
54
+ *
55
+ * Monotonicity protects the *contents*. It does not protect the file's
56
+ * *existence*: an agent that deletes `.acuvo/policy.json` faces no workspace
57
+ * restrictions on the NEXT run, because there is nothing left to read. Nothing
58
+ * inside the workspace can fix that — the workspace is the thing under the
59
+ * agent's hands.
60
+ *
61
+ * So the two layers mean genuinely different things, and the documentation must
62
+ * not blur them:
63
+ *
64
+ * **The workspace policy is a project CONVENTION.** Committable, reviewable in
65
+ * a PR, shared by the team, and adequate against mistakes.
66
+ *
67
+ * **The admin policy is a CONTROL.** It lives at `$ACUVO_POLICY_FILE` or
68
+ * `~/.acuvo/policy.json` — outside the workspace, where the agent has no verb
69
+ * that reaches it: every path is refused before it leaves the root
70
+ * (`workspace.mjs:114`) and there is no shell to escape through
71
+ * (`command.mjs`). An org that needs enforcement rather than agreement puts
72
+ * the file there and owns the file permissions. Anyone who ships only the
73
+ * workspace half and calls it enterprise policy is lying.
74
+ *
75
+ * ⭐ And the mitigation for the deletion hole is one line in someone else's file,
76
+ * which is why it is a reported recommendation and not code here: adding
77
+ * `.acuvo` to `WRITE_FORBIDDEN_ROOTS` makes the whole directory read-but-never-
78
+ * write. That also closes the strictly worse hole next door — `.acuvo/mcp.json`
79
+ * is writable today, and MCP config names a **binary we spawn**
80
+ * (`mcp.mjs:256`), so the agent can currently choose a program for the next run.
81
+ *
82
+ * ── ⚠️ FAIL CLOSED MEANS THE RUN DOES NOT START ─────────────────────────────
83
+ *
84
+ * A malformed policy returns `{ ok: false }` and the caller must refuse to run.
85
+ * It must never fall back to "no policy". The failure mode being designed
86
+ * against is a trailing comma in a JSON file quietly restoring an unrestricted
87
+ * agent — the one bug where the symptom is *everything works fine*.
88
+ *
89
+ * ⭐ Unknown keys are an error for the same reason, and it is the subtle one:
90
+ * `{"maxRoundz": 2}` is valid JSON, sets nothing, and reads to a human as a cap.
91
+ * A typo that silently means "unlimited" is the exact shape of the accident this
92
+ * file exists to prevent, so every key is checked against the known set and a
93
+ * stranger stops the run.
94
+ *
95
+ * Pure throughout. The filesystem lives in exactly one function at the bottom,
96
+ * and it takes its reader as an argument.
97
+ */
98
+
99
+ import { TOOL_NAMES } from './tools.mjs';
100
+
101
+ /** Where a team commits its convention. Same `.acuvo/` directory as `mcp.json`. */
102
+ export const WORKSPACE_POLICY_FILE = '.acuvo/policy.json';
103
+ /** Where an admin puts the real control, and the env var that relocates it. */
104
+ export const USER_POLICY_FILE = '.acuvo/policy.json';
105
+ export const USER_POLICY_ENV = 'ACUVO_POLICY_FILE';
106
+
107
+ /**
108
+ * A round is a paid completion, so an unbounded `maxRounds` in a config file is
109
+ * a blank cheque. This is not the default — it is the largest number the parser
110
+ * will accept as a deliberate statement.
111
+ */
112
+ export const MAX_ROUNDS_CEILING = 64;
113
+ /** Above this a "cap" is decoration. Refused so a fat finger cannot pass review. */
114
+ export const MAX_COST_CEILING_USD = 1000;
115
+
116
+ /**
117
+ * The keys a policy document may contain. Anything else stops the run — see the
118
+ * header on why a typo that means "unlimited" is the dangerous kind.
119
+ */
120
+ const KNOWN_KEYS = new Set([
121
+ 'forbidTools', 'allowTools', 'maxRounds', 'maxCostUsd',
122
+ 'requireDryRun', 'allowModels', 'denyModels', 'allowMcp',
123
+ 'allowWorkspacePolicy',
124
+ ]);
125
+
126
+ /**
127
+ * ⚠️ ADMIN-ONLY KEYS. `allowWorkspacePolicy` selects which layers are read, so
128
+ * it is the one field that is NOT a lattice value — a workspace file that could
129
+ * set it would be reaching outside the "may only restrict" guarantee to change
130
+ * the merge itself. A workspace file that names it is refused rather than
131
+ * ignored, because silently dropping a key someone wrote is how a config file
132
+ * starts lying to its author.
133
+ */
134
+ const ADMIN_ONLY_KEYS = new Set(['allowWorkspacePolicy']);
135
+
136
+ /**
137
+ * The identity of the lattice: the policy you have when nobody has said
138
+ * anything. It is deliberately PERMISSIVE — adding this module to the CLI must
139
+ * change zero behaviour for the ninety-nine users who never write the file.
140
+ * Restriction is opt-in; the safety floor is still the allowlists in
141
+ * `command.mjs` and `workspace.mjs`, which no policy can lower or raise.
142
+ */
143
+ export const OPEN_POLICY = Object.freeze({
144
+ forbidTools: Object.freeze([]),
145
+ /** `null` means "no whitelist", which is different from an empty whitelist. */
146
+ allowTools: null,
147
+ maxRounds: null,
148
+ maxCostUsd: null,
149
+ requireDryRun: false,
150
+ allowModels: null,
151
+ denyModels: Object.freeze([]),
152
+ allowMcp: true,
153
+ allowWorkspacePolicy: true,
154
+ sources: Object.freeze([]),
155
+ });
156
+
157
+ // ── parsing ────────────────────────────────────────────────────────────────
158
+
159
+ function isPlainObject(v) {
160
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
161
+ }
162
+
163
+ /**
164
+ * A string list, validated element by element.
165
+ *
166
+ * ⚠️ Refuses a bare string where a list belongs. `"forbidTools": "run_command"`
167
+ * is what a human writes on the first attempt, and treating a string as an
168
+ * iterable of characters would forbid the tools `r`, `u`, `n`… and nothing else.
169
+ * That is a fail-open typo, so it stops the run.
170
+ */
171
+ function readStringList(value, key, label) {
172
+ if (!Array.isArray(value)) {
173
+ return { ok: false, error: `${label}: "${key}" must be an array of strings${typeof value === 'string' ? ` (got the string ${JSON.stringify(value)} — wrap it: ["${value}"])` : ''}` };
174
+ }
175
+ const out = [];
176
+ for (const item of value) {
177
+ if (typeof item !== 'string' || !item.trim()) {
178
+ return { ok: false, error: `${label}: "${key}" contains ${JSON.stringify(item)}, which is not a non-empty string` };
179
+ }
180
+ out.push(item.trim());
181
+ }
182
+ return { ok: true, value: out };
183
+ }
184
+
185
+ /**
186
+ * ⭐ A TOOL NAME IS CHECKED AGAINST THE REGISTRY, and this is the second
187
+ * fail-open typo. `"forbidTools": ["run_commands"]` forbids nothing at all and
188
+ * looks, in a code review, exactly like a policy that forbids shelling out. The
189
+ * registry is right here (`tools.mjs`), so a name that cannot possibly match is
190
+ * an error naming the near miss.
191
+ *
192
+ * ⚠️ MCP tools are exempt from the registry check and cannot be otherwise: they
193
+ * are namespaced `mcp__<server>__<tool>` (`mcp.mjs:316`) and are discovered at
194
+ * runtime by handshaking with a server, so at parse time we genuinely do not
195
+ * know them. They are validated by SHAPE instead, which still catches a typo in
196
+ * the prefix.
197
+ */
198
+ function checkToolNames(names, key, label, knownTools) {
199
+ for (const name of names) {
200
+ if (name === '*') continue;
201
+ if (name.startsWith('mcp__')) {
202
+ if (!/^mcp__[a-z0-9_-]+__[^\s]+$/i.test(name)) {
203
+ return { ok: false, error: `${label}: "${key}" has "${name}", which looks like an MCP tool but is not shaped mcp__<server>__<tool>` };
204
+ }
205
+ continue;
206
+ }
207
+ if (!knownTools.includes(name)) {
208
+ const near = knownTools.filter((k) => k.startsWith(name.slice(0, 4)));
209
+ return {
210
+ ok: false,
211
+ error: `${label}: "${key}" names "${name}", which is not a tool this agent has${near.length ? `. Did you mean ${near.slice(0, 3).map((n) => `"${n}"`).join(' or ')}?` : `. Known: ${knownTools.join(', ')}`}`,
212
+ };
213
+ }
214
+ }
215
+ return { ok: true };
216
+ }
217
+
218
+ function readBounded(value, key, label, { max, integer }) {
219
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
220
+ return { ok: false, error: `${label}: "${key}" must be a number (got ${JSON.stringify(value)})` };
221
+ }
222
+ if (integer && !Number.isInteger(value)) {
223
+ return { ok: false, error: `${label}: "${key}" must be a whole number (got ${value})` };
224
+ }
225
+ // ⚠️ Zero is a legal, meaningful cap ("spend nothing", "run no rounds") and is
226
+ // NOT the same as absent. Negative is a typo that would compare as "always
227
+ // exceeded" or "never reached" depending on which way the check is written,
228
+ // so it never reaches a comparison.
229
+ if (value < 0) return { ok: false, error: `${label}: "${key}" cannot be negative (got ${value})` };
230
+ if (value > max) return { ok: false, error: `${label}: "${key}" of ${value} exceeds the maximum ${max} this parser will accept as deliberate` };
231
+ return { ok: true, value };
232
+ }
233
+
234
+ function readBoolean(value, key, label) {
235
+ // ⚠️ Not truthiness. `"requireDryRun": "false"` is a truthy string, and the
236
+ // opposite reading is the dangerous one for every boolean here.
237
+ if (typeof value !== 'boolean') {
238
+ return { ok: false, error: `${label}: "${key}" must be true or false (got ${JSON.stringify(value)})` };
239
+ }
240
+ return { ok: true, value };
241
+ }
242
+
243
+ /**
244
+ * Parse ONE layer of policy from its JSON text.
245
+ *
246
+ * Returns a PARTIAL policy — only the keys the document actually stated — so
247
+ * that merging can tell "said nothing" apart from "said the permissive thing".
248
+ *
249
+ * @param {string} text
250
+ * @param {{ label?: string, admin?: boolean, knownTools?: readonly string[] }} [opts]
251
+ */
252
+ export function parsePolicyDocument(text, { label = 'policy', admin = false, knownTools = TOOL_NAMES } = {}) {
253
+ if (typeof text !== 'string') return { ok: false, error: `${label}: expected the file's text` };
254
+ const trimmed = text.trim();
255
+ // An empty file is a mistake with two readings ("I meant to write a policy"
256
+ // and "I meant no policy") and only one of them is safe to guess, so neither
257
+ // is guessed.
258
+ if (!trimmed) return { ok: false, error: `${label}: the file is empty. Write {} for a policy that restricts nothing.` };
259
+
260
+ let doc;
261
+ try {
262
+ doc = JSON.parse(trimmed);
263
+ } catch (err) {
264
+ return { ok: false, error: `${label}: not valid JSON — ${err instanceof Error ? err.message : String(err)}` };
265
+ }
266
+ if (!isPlainObject(doc)) {
267
+ return { ok: false, error: `${label}: the top level must be a JSON object` };
268
+ }
269
+
270
+ for (const key of Object.keys(doc)) {
271
+ if (!KNOWN_KEYS.has(key)) {
272
+ return { ok: false, error: `${label}: unknown setting "${key}". A misspelled cap sets nothing and reads like a cap. Known: ${[...KNOWN_KEYS].join(', ')}` };
273
+ }
274
+ if (!admin && ADMIN_ONLY_KEYS.has(key)) {
275
+ return { ok: false, error: `${label}: "${key}" may only be set in the admin policy (${USER_POLICY_ENV} or ~/${USER_POLICY_FILE}), not in the workspace` };
276
+ }
277
+ }
278
+
279
+ const out = {};
280
+
281
+ for (const key of ['forbidTools', 'allowTools', 'denyModels', 'allowModels']) {
282
+ if (!(key in doc)) continue;
283
+ const list = readStringList(doc[key], key, label);
284
+ if (!list.ok) return list;
285
+ if (key === 'forbidTools' || key === 'allowTools') {
286
+ const named = checkToolNames(list.value, key, label, knownTools);
287
+ if (!named.ok) return named;
288
+ }
289
+ out[key] = list.value;
290
+ }
291
+
292
+ if ('maxRounds' in doc) {
293
+ const r = readBounded(doc.maxRounds, 'maxRounds', label, { max: MAX_ROUNDS_CEILING, integer: true });
294
+ if (!r.ok) return r;
295
+ out.maxRounds = r.value;
296
+ }
297
+ if ('maxCostUsd' in doc) {
298
+ const r = readBounded(doc.maxCostUsd, 'maxCostUsd', label, { max: MAX_COST_CEILING_USD, integer: false });
299
+ if (!r.ok) return r;
300
+ out.maxCostUsd = r.value;
301
+ }
302
+ for (const key of ['requireDryRun', 'allowMcp', 'allowWorkspacePolicy']) {
303
+ if (!(key in doc)) continue;
304
+ const r = readBoolean(doc[key], key, label);
305
+ if (!r.ok) return r;
306
+ out[key] = r.value;
307
+ }
308
+
309
+ return { ok: true, policy: out };
310
+ }
311
+
312
+ // ── the lattice ────────────────────────────────────────────────────────────
313
+
314
+ const union = (a, b) => [...new Set([...a, ...b])].sort();
315
+
316
+ /**
317
+ * Does `broad` permit everything `narrow` permits?
318
+ *
319
+ * ⚠️ THIS IS WHY INTERSECTING WHITELISTS IS NOT `Set.has`, and the naive version
320
+ * was written here first and caught by a test. Literal membership makes
321
+ * `["deepseek/*"] ∩ ["*"]` **empty** — nothing allowed — because the string `*`
322
+ * is not the string `deepseek/*`. It fails in the safe direction, so it is not a
323
+ * hole, but it is still wrong twice over: a workspace file saying the permissive
324
+ * thing would brick every run, and `["deepseek/*"] ∩ ["deepseek/deepseek-chat"]`
325
+ * would refuse a model both layers plainly allow.
326
+ *
327
+ * A whitelist is a set of PATTERNS, so the intersection has to be taken over
328
+ * what they MATCH, not over how they are spelled.
329
+ */
330
+ function patternSubsumes(broad, narrow) {
331
+ if (broad === '*') return true;
332
+ if (broad.endsWith('/*')) return narrow === broad || narrow.startsWith(broad.slice(0, -1));
333
+ return broad === narrow;
334
+ }
335
+
336
+ /**
337
+ * Intersect two whitelists, where `null` means "did not restrict".
338
+ *
339
+ * Keeps every pattern from either side that the other side already permits —
340
+ * which for the three shapes we support (`*`, `vendor/*`, an exact id) is
341
+ * exactly their intersection, expressed in the same three shapes.
342
+ *
343
+ * ⭐ Still monotone: the result can never match something neither input matched,
344
+ * which is the property the whole file rests on.
345
+ */
346
+ function intersect(a, b) {
347
+ if (a === null) return b === null ? null : [...b].sort();
348
+ if (b === null) return [...a].sort();
349
+ const kept = [
350
+ ...a.filter((x) => b.some((y) => patternSubsumes(y, x))),
351
+ ...b.filter((y) => a.some((x) => patternSubsumes(x, y))),
352
+ ];
353
+ return [...new Set(kept)].sort();
354
+ }
355
+
356
+ /** Take the smaller cap, where `null` means "did not cap". */
357
+ function minCap(a, b) {
358
+ if (a === null || a === undefined) return b ?? null;
359
+ if (b === null || b === undefined) return a;
360
+ return Math.min(a, b);
361
+ }
362
+
363
+ /**
364
+ * ⭐⭐ THE MEET. Every field moves one way only: toward less permission.
365
+ *
366
+ * This is the function the header is about. Because it is commutative,
367
+ * idempotent and monotone downward, a layer written by the agent itself cannot
368
+ * grant the agent anything — the worst a hostile `.acuvo/policy.json` achieves
369
+ * is a stricter run. It also means the merge order is not load-bearing, so
370
+ * nobody has to reason about precedence to audit what is in force.
371
+ *
372
+ * ⚠️ The one field it does NOT merge is `allowWorkspacePolicy`, which decides
373
+ * whether a layer is read at all and is settled before we get here.
374
+ */
375
+ export function mergePolicies(base, layer) {
376
+ return {
377
+ forbidTools: union(base.forbidTools ?? [], layer.forbidTools ?? []),
378
+ allowTools: intersect(base.allowTools ?? null, layer.allowTools ?? null),
379
+ maxRounds: minCap(base.maxRounds, layer.maxRounds),
380
+ maxCostUsd: minCap(base.maxCostUsd, layer.maxCostUsd),
381
+ // OR: once anyone has said "nothing may touch disk", nobody can unsay it.
382
+ requireDryRun: Boolean(base.requireDryRun) || Boolean(layer.requireDryRun),
383
+ allowModels: intersect(base.allowModels ?? null, layer.allowModels ?? null),
384
+ denyModels: union(base.denyModels ?? [], layer.denyModels ?? []),
385
+ // AND: one `false` anywhere means no server is spawned.
386
+ allowMcp: base.allowMcp !== false && layer.allowMcp !== false,
387
+ allowWorkspacePolicy: base.allowWorkspacePolicy !== false,
388
+ sources: base.sources ?? [],
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Build the effective policy from the layer texts.
394
+ *
395
+ * PURE — it takes text, not paths, which is why the whole suite tests the real
396
+ * decision logic without touching a disk.
397
+ *
398
+ * @param {{ adminText?: string|null, adminLabel?: string, workspaceText?: string|null, workspaceLabel?: string, knownTools?: readonly string[] }} input
399
+ */
400
+ export function loadPolicy({
401
+ adminText = null, adminLabel = `~/${USER_POLICY_FILE}`,
402
+ workspaceText = null, workspaceLabel = WORKSPACE_POLICY_FILE,
403
+ knownTools = TOOL_NAMES,
404
+ } = {}) {
405
+ let effective = { ...OPEN_POLICY, forbidTools: [], denyModels: [], sources: [] };
406
+ const sources = [];
407
+
408
+ if (adminText !== null && adminText !== undefined) {
409
+ const parsed = parsePolicyDocument(adminText, { label: adminLabel, admin: true, knownTools });
410
+ if (!parsed.ok) return parsed;
411
+ effective = mergePolicies(effective, parsed.policy);
412
+ if (parsed.policy.allowWorkspacePolicy === false) effective.allowWorkspacePolicy = false;
413
+ sources.push({ label: adminLabel, trusted: true });
414
+ }
415
+
416
+ if (workspaceText !== null && workspaceText !== undefined) {
417
+ /**
418
+ * ⚠️ An admin can switch the workspace layer off entirely. Redundant against
419
+ * a hostile agent (it could not widen anything anyway) but not against a
420
+ * hostile HUMAN: a contractor committing `{"maxRounds": 0}` would otherwise
421
+ * be able to brick every run on the machine, and "the agent refuses to do
422
+ * anything and nobody knows why" is a support ticket, not a security win.
423
+ */
424
+ if (effective.allowWorkspacePolicy === false) {
425
+ sources.push({ label: workspaceLabel, trusted: false, ignored: true });
426
+ } else {
427
+ const parsed = parsePolicyDocument(workspaceText, { label: workspaceLabel, admin: false, knownTools });
428
+ if (!parsed.ok) return parsed;
429
+ effective = mergePolicies(effective, parsed.policy);
430
+ sources.push({ label: workspaceLabel, trusted: false });
431
+ }
432
+ }
433
+
434
+ effective.sources = sources;
435
+ return { ok: true, policy: effective };
436
+ }
437
+
438
+ // ── decisions ──────────────────────────────────────────────────────────────
439
+
440
+ /**
441
+ * May this tool be offered and dispatched?
442
+ *
443
+ * ⚠️ THE ANSWER MUST BE USED IN BOTH PLACES. Filtering the OFFER stops the model
444
+ * wasting a round on a dead button; checking at DISPATCH is what makes it a
445
+ * control, because a model can emit a call for a tool it was never shown (and
446
+ * MCP tools arrive after the offer is built). Doing only the first is a UI, not
447
+ * a policy.
448
+ */
449
+ export function toolDecision(policy, name) {
450
+ if (typeof name !== 'string' || !name) {
451
+ return { allowed: false, reason: 'a tool call with no name is refused' };
452
+ }
453
+ if (policy.forbidTools?.includes('*')) {
454
+ return { allowed: false, reason: 'policy forbids every tool' };
455
+ }
456
+ if (policy.forbidTools?.includes(name)) {
457
+ return { allowed: false, reason: `policy forbids ${name}` };
458
+ }
459
+ if (policy.allowTools) {
460
+ if (policy.allowTools.includes('*')) return { allowed: true, reason: null };
461
+ if (!policy.allowTools.includes(name)) {
462
+ return { allowed: false, reason: `policy allows only ${policy.allowTools.join(', ')} — ${name} is not on the list` };
463
+ }
464
+ }
465
+ return { allowed: true, reason: null };
466
+ }
467
+
468
+ /** The offer side of the same decision, order preserved (the order is a hint —
469
+ * see `tools.mjs` on edit-before-write). */
470
+ export function filterToolNames(policy, names) {
471
+ return names.filter((n) => toolDecision(policy, n).allowed);
472
+ }
473
+
474
+ /**
475
+ * Model patterns: an exact id, `vendor/*`, or `*`.
476
+ *
477
+ * ⚠️ DELIBERATELY NOT A REGEX AND NOT A GENERAL GLOB. `search.mjs:83` already
478
+ * carries the note that compiling a foreign string with `new RegExp` is a
479
+ * catastrophic-backtracking DoS in one call, and a policy file is exactly the
480
+ * kind of foreign string. Two shapes cover every real rule ("only DeepSeek",
481
+ * "only this pinned id") and neither can be made to hang.
482
+ */
483
+ export function matchesModelPattern(pattern, id) {
484
+ if (pattern === '*') return true;
485
+ if (pattern.endsWith('/*')) return id.startsWith(pattern.slice(0, -1));
486
+ return pattern === id;
487
+ }
488
+
489
+ /**
490
+ * May this model id be called?
491
+ *
492
+ * ⚠️ THIS IS THE FIELD MOST LIKELY TO BE ENFORCED IN THE WRONG PLACE. The chain
493
+ * (`chain.mjs:56`) substitutes a DIFFERENT model on a retry, and `buildChain`
494
+ * appends three defaults nobody asked for — so a check at the top of the run
495
+ * proves nothing about the model that actually answered. The call site has to be
496
+ * inside the chain loop, per candidate. A jurisdiction rule ("nothing routed via
497
+ * a Chinese provider") is worthless if the fallback quietly ignores it.
498
+ */
499
+ export function modelDecision(policy, id) {
500
+ if (typeof id !== 'string' || !id) return { allowed: false, reason: 'an empty model id is refused' };
501
+ for (const p of policy.denyModels ?? []) {
502
+ if (matchesModelPattern(p, id)) return { allowed: false, reason: `policy denies model ${id} (matched "${p}")` };
503
+ }
504
+ if (policy.allowModels) {
505
+ const hit = policy.allowModels.some((p) => matchesModelPattern(p, id));
506
+ if (!hit) return { allowed: false, reason: `policy allows only ${policy.allowModels.join(', ')} — ${id} is not among them` };
507
+ }
508
+ return { allowed: true, reason: null };
509
+ }
510
+
511
+ /** Lower a requested round budget to the policy ceiling. Never raises it — a
512
+ * policy is a limit, not a default. */
513
+ export function roundBudget(policy, requested) {
514
+ const cap = policy.maxRounds;
515
+ if (cap === null || cap === undefined) return { rounds: requested, capped: false, reason: null };
516
+ if (requested <= cap) return { rounds: requested, capped: false, reason: null };
517
+ return { rounds: cap, capped: true, reason: `policy caps rounds at ${cap} (you asked for ${requested})` };
518
+ }
519
+
520
+ /**
521
+ * ── ⭐⭐ THE COST CAP, APPLIED — IT WAS ENFORCED BY NOTHING ──────────────────
522
+ *
523
+ * `costDecision` below is complete, careful, and had ZERO runtime callers. It
524
+ * was imported only by its own test. PROVEN LIVE: a workspace `.acuvo/policy.json`
525
+ * of `{"maxCostUsd": 0}` parsed correctly, `costDecision` returned
526
+ * `{stop:true, reason:"policy cost cap reached"}` when asked — and the run
527
+ * completed three rounds and spent money, because nobody asked. The decision
528
+ * function said STOP into an empty room.
529
+ *
530
+ * ⭐ THIS IS THE SIBLING OF `roundBudget`, DELIBERATELY. That function exists
531
+ * because a round cap returned as a NOTE and never applied is "an announcement
532
+ * of a limit that is not enforced" — bin/acuvo.mjs says exactly that, three
533
+ * lines above where this now gets called. The cost cap had the same disease and
534
+ * the same cure: fold it into the ceiling the governor already reads, rather
535
+ * than bolting a second check onto the round loop where a future call site can
536
+ * forget it.
537
+ *
538
+ * ⚠️ THE ADMIN'S NUMBER WINS WHEN IT IS TIGHTER, AND ONLY THEN. An admin cap
539
+ * that a user could RAISE with `--budget` is not a cap; one that could LOWER a
540
+ * user's own stricter choice would be an admin file spending more of their money
541
+ * than they asked to. So it is a MINIMUM of the two, which is the only rule that
542
+ * is safe in both directions.
543
+ *
544
+ * ⚠️ AND `0` MEANS ZERO, NOT ABSENT. `policy.test.mjs` already pins that reading
545
+ * ("spend nothing, not absent"), so the comparison must be against null/undefined
546
+ * rather than falsiness — `cap || requested` would turn the strictest possible
547
+ * policy into no policy at all, silently.
548
+ *
549
+ * @param {{ maxCostUsd?: number | null }} policy
550
+ * @param {number | null} requested the ceiling the user asked for, or null for none
551
+ * @returns {{ usd: number | null, capped: boolean, reason: string | null }}
552
+ */
553
+ export function costBudget(policy, requested) {
554
+ const cap = policy.maxCostUsd;
555
+ if (cap === null || cap === undefined) return { usd: requested, capped: false, reason: null };
556
+ if (requested === null || requested === undefined || !Number.isFinite(requested)) {
557
+ return { usd: cap, capped: true, reason: `policy caps spend at $${cap.toFixed(4)} per run` };
558
+ }
559
+ if (requested <= cap) return { usd: requested, capped: false, reason: null };
560
+ return {
561
+ usd: cap,
562
+ capped: true,
563
+ reason: `policy caps spend at $${cap.toFixed(4)} per run (you asked for $${requested.toFixed(4)})`,
564
+ };
565
+ }
566
+
567
+ /**
568
+ * ── ⭐ THE COST CAP, AND THE TWO WAYS IT IS USUALLY BUILT WRONG ─────────────
569
+ *
570
+ * (1) **A cap checked after the fact is a cap that is always breached once.**
571
+ * `spent >= cap → stop` lets the round that crosses the line complete and bill.
572
+ * So the next round's likely cost is PROJECTED from the last one and the stop
573
+ * happens before the spend, not after it. `lastRoundUsd` is the estimator we
574
+ * have; it is not a promise, and the reason string says "would".
575
+ *
576
+ * (2) ⚠️⚠️ **A provider that reports no cost must not read as free.**
577
+ * `aggregateUsage` (`turn.mjs:1136`) returns `null` when nothing reported usage,
578
+ * and `null` coerced through arithmetic becomes 0 — a $0.00 running total under
579
+ * a $5 cap, forever. Fail closed: an unenforceable cap stops the run and says
580
+ * why. One round is allowed first, because you cannot learn whether a provider
581
+ * reports cost without spending one.
582
+ */
583
+ export function costDecision(policy, { spentUsd = null, roundsUsed = 0, lastRoundUsd = null } = {}) {
584
+ const cap = policy.maxCostUsd;
585
+ if (cap === null || cap === undefined) return { ok: true, stop: false, reason: null };
586
+
587
+ if (typeof spentUsd !== 'number' || !Number.isFinite(spentUsd)) {
588
+ if (roundsUsed < 1) return { ok: true, stop: false, reason: null };
589
+ return {
590
+ ok: false,
591
+ stop: true,
592
+ reason: `policy caps spend at $${cap.toFixed(4)} but the provider reported no cost, so the cap cannot be enforced — stopping rather than spending blind`,
593
+ };
594
+ }
595
+
596
+ if (spentUsd >= cap) {
597
+ return { ok: false, stop: true, reason: `policy cost cap reached: $${spentUsd.toFixed(6)} of $${cap.toFixed(4)}` };
598
+ }
599
+ const projected = spentUsd + (typeof lastRoundUsd === 'number' && Number.isFinite(lastRoundUsd) ? lastRoundUsd : 0);
600
+ if (projected > cap) {
601
+ return {
602
+ ok: false,
603
+ stop: true,
604
+ reason: `stopping at $${spentUsd.toFixed(6)}: another round would cost about $${Number(lastRoundUsd).toFixed(6)} and exceed the $${cap.toFixed(4)} cap`,
605
+ };
606
+ }
607
+ return { ok: true, stop: false, reason: null };
608
+ }
609
+
610
+ /** MCP is one boolean, but it is the one that decides whether a foreign binary
611
+ * is spawned, so it gets a named decision rather than an inline `if`. */
612
+ export function mcpDecision(policy) {
613
+ return policy.allowMcp === false
614
+ ? { allowed: false, reason: 'policy disables MCP — no server is spawned and no remote tool is offered' }
615
+ : { allowed: true, reason: null };
616
+ }
617
+
618
+ /**
619
+ * Check the whole invocation before a single completion is bought.
620
+ *
621
+ * ⭐ REFUSE UP FRONT RATHER THAN CLAMP SILENTLY. `--max-rounds` over the cap is
622
+ * lowered (a ceiling is a ceiling, and the run is still what was asked for), but
623
+ * `requireDryRun` without `--dry-run` is a different intent entirely — running
624
+ * it "safely" would mean doing a job the user did not ask for and reporting
625
+ * success. That one stops with a sentence naming the flag to add.
626
+ *
627
+ * @param {object} policy
628
+ * @param {{ dryRun?: boolean, allowRun?: boolean, maxRounds?: number, model?: string|null }} options
629
+ */
630
+ export function invocationDecision(policy, options = {}) {
631
+ const violations = [];
632
+ const notes = [];
633
+
634
+ if (policy.requireDryRun && !options.dryRun) {
635
+ violations.push('policy requires --dry-run: this agent may plan and print, but may not write to disk here');
636
+ }
637
+ if (options.model) {
638
+ const m = modelDecision(policy, options.model);
639
+ if (!m.allowed) violations.push(m.reason);
640
+ }
641
+ if (typeof options.maxRounds === 'number') {
642
+ const r = roundBudget(policy, options.maxRounds);
643
+ if (r.capped) notes.push(r.reason);
644
+ }
645
+ /**
646
+ * ⚠️ `forbidTools: ["run_command"]` and `--no-run` are the same intent
647
+ * expressed twice, and only one of them currently reaches `evaluate` and
648
+ * `git_commit` (`tools.mjs:toolNamesForRounds`). Surfacing it as a note keeps
649
+ * the two from drifting into "the policy said no and the flag said yes".
650
+ */
651
+ if (options.allowRun === true && (policy.forbidTools ?? []).includes('run_command')) {
652
+ notes.push('policy forbids run_command, so nothing will be executed even though --no-run was not passed');
653
+ }
654
+
655
+ return { ok: violations.length === 0, violations, notes };
656
+ }
657
+
658
+ // ── the file itself ────────────────────────────────────────────────────────
659
+
660
+ /**
661
+ * ⭐ THE POLICY DIRECTORY IS NOT THE AGENT'S TO EDIT, and this is the check that
662
+ * says so at the point of a write.
663
+ *
664
+ * It is defence in depth, NOT the security boundary — the boundary is the
665
+ * monotone merge above, because this check lives in the same process as the
666
+ * agent and only covers the verbs that consult it. It is here because an agent
667
+ * that "helpfully" rewrites the config governing it is a confusing failure even
668
+ * when it is a harmless one, and because the same directory holds `mcp.json`,
669
+ * where a write genuinely is remote code execution on the next run.
670
+ *
671
+ * ⚠️ Takes an ALREADY-NORMALISED workspace-relative path (see
672
+ * `normalizeRelativePath`, `workspace.mjs:114`). Given a raw model string it
673
+ * would be comparing against `.\.acuvo\policy.json` and every other spelling.
674
+ */
675
+ export function isPolicyProtectedPath(relPath) {
676
+ if (typeof relPath !== 'string') return false;
677
+ return relPath === '.acuvo' || relPath.startsWith('.acuvo/');
678
+ }
679
+
680
+ /**
681
+ * A short stable id for the effective policy, for the audit line and for
682
+ * comparing two machines' configuration in a bug report.
683
+ *
684
+ * ⚠️ NOT A SECURITY MECHANISM AND MUST NEVER BE DESCRIBED AS ONE. FNV-1a, chosen
685
+ * because this package has zero dependencies and no reason to acquire a crypto
686
+ * import for a label. It answers "are these two runs governed by the same
687
+ * rules?", never "did someone tamper with this?" — the header explains why the
688
+ * tamper question is not answerable from inside the workspace anyway.
689
+ */
690
+ export function policyFingerprint(policy) {
691
+ const canonical = JSON.stringify([
692
+ policy.forbidTools ?? [], policy.allowTools ?? null,
693
+ policy.maxRounds ?? null, policy.maxCostUsd ?? null,
694
+ Boolean(policy.requireDryRun), policy.allowModels ?? null,
695
+ policy.denyModels ?? [], policy.allowMcp !== false,
696
+ ]);
697
+ let h = 0x811c9dc5;
698
+ for (let i = 0; i < canonical.length; i += 1) {
699
+ h ^= canonical.charCodeAt(i);
700
+ h = Math.imul(h, 0x01000193) >>> 0;
701
+ }
702
+ return h.toString(16).padStart(8, '0');
703
+ }
704
+
705
+ /**
706
+ * The lines to print when a policy is in force.
707
+ *
708
+ * ⭐ A POLICY NOBODY CAN SEE IS A POLICY NOBODY TRUSTS — and worse, it is
709
+ * indistinguishable from a broken agent. "It refused to run the tests" needs to
710
+ * read as "your policy forbids run_command", or the next hour is spent debugging
711
+ * the CLI. Returns `[]` when nothing is restricted, so an unconfigured user
712
+ * never sees a line about a feature they are not using.
713
+ */
714
+ export function describePolicy(policy) {
715
+ const lines = [];
716
+ if (policy.requireDryRun) lines.push('dry-run only — nothing will be written');
717
+ if (policy.allowTools) lines.push(`tools limited to: ${policy.allowTools.join(', ')}`);
718
+ if (policy.forbidTools?.length) lines.push(`tools forbidden: ${policy.forbidTools.join(', ')}`);
719
+ if (policy.maxRounds !== null && policy.maxRounds !== undefined) lines.push(`at most ${policy.maxRounds} round${policy.maxRounds === 1 ? '' : 's'}`);
720
+ if (policy.maxCostUsd !== null && policy.maxCostUsd !== undefined) lines.push(`at most $${policy.maxCostUsd} per run`);
721
+ if (policy.allowModels) lines.push(`models limited to: ${policy.allowModels.join(', ')}`);
722
+ if (policy.denyModels?.length) lines.push(`models denied: ${policy.denyModels.join(', ')}`);
723
+ if (policy.allowMcp === false) lines.push('MCP disabled — no external server will be spawned');
724
+ if (!lines.length) return [];
725
+ const where = (policy.sources ?? []).map((s) => `${s.label}${s.ignored ? ' (ignored)' : ''}`).join(' + ') || 'defaults';
726
+ return [`policy [${policyFingerprint(policy)}] from ${where}:`, ...lines.map((l) => ` · ${l}`)];
727
+ }
728
+
729
+ /**
730
+ * The ONLY function here that touches a disk, and it takes its reader as an
731
+ * argument so nothing above it needs one.
732
+ *
733
+ * ⚠️ ABSENT IS NOT MALFORMED. A missing file is the overwhelmingly common case
734
+ * and means "no policy at this layer"; an unreadable-but-present file is a
735
+ * broken control and stops the run. Conflating them is how fail-closed quietly
736
+ * becomes fail-open on a permissions error.
737
+ *
738
+ * @param {string} root workspace root
739
+ * @param {{ env?: object, home?: string|null, readFileImpl?: (p: string) => string, joinImpl?: (...p: string[]) => string }} [deps]
740
+ */
741
+ export function readPolicySources(root, { env = process.env, home = null, readFileImpl, joinImpl } = {}) {
742
+ const join = joinImpl ?? ((...p) => p.join('/').replace(/\/+/g, '/'));
743
+ const read = readFileImpl;
744
+ if (typeof read !== 'function') return { ok: false, error: 'readPolicySources needs a reader' };
745
+
746
+ const slurp = (abs) => {
747
+ try {
748
+ return { found: true, text: read(abs) };
749
+ } catch (err) {
750
+ const code = err && typeof err === 'object' ? err.code : null;
751
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { found: false, text: null };
752
+ return { found: true, text: null, error: `${abs}: ${err instanceof Error ? err.message : String(err)}` };
753
+ }
754
+ };
755
+
756
+ const override = String(env[USER_POLICY_ENV] ?? '').trim();
757
+ const adminPath = override || (home ? join(home, USER_POLICY_FILE) : null);
758
+ let adminText = null;
759
+ if (adminPath) {
760
+ const r = slurp(adminPath);
761
+ if (r.error) return { ok: false, error: `admin policy could not be read — ${r.error}` };
762
+ /**
763
+ * ⚠️ AN EXPLICIT `ACUVO_POLICY_FILE` POINTING AT NOTHING IS AN ERROR, while
764
+ * an absent `~/.acuvo/policy.json` is not. Somebody set that variable on
765
+ * purpose; a typo in it silently meaning "unrestricted" is the deployment
766
+ * accident this whole file is built to refuse.
767
+ */
768
+ if (!r.found && override) return { ok: false, error: `${USER_POLICY_ENV} points at ${override}, which does not exist` };
769
+ adminText = r.found ? r.text : null;
770
+ }
771
+
772
+ const wsPath = join(root, WORKSPACE_POLICY_FILE);
773
+ const w = slurp(wsPath);
774
+ if (w.error) return { ok: false, error: `workspace policy could not be read — ${w.error}` };
775
+
776
+ return {
777
+ ok: true,
778
+ adminText,
779
+ adminLabel: adminPath ?? `~/${USER_POLICY_FILE}`,
780
+ workspaceText: w.found ? w.text : null,
781
+ workspaceLabel: WORKSPACE_POLICY_FILE,
782
+ };
783
+ }