@bevel-software/platform-core-backend 0.22.0 → 0.24.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 (466) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +46 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +12 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +137 -24
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +46 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +99 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +16 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +28 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +6 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +12 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +17 -16
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  34. package/dist/modules/access/directory-sync-bot.js +7 -3
  35. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  36. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  37. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  38. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  39. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  40. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  41. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  42. package/dist/modules/agent-instructions/compose.js +39 -6
  43. package/dist/modules/agent-instructions/compose.js.map +1 -1
  44. package/dist/modules/agent-instructions/index.d.ts +2 -1
  45. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  46. package/dist/modules/agent-instructions/index.js +2 -1
  47. package/dist/modules/agent-instructions/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  49. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  50. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  51. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  52. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  53. package/dist/modules/audit/agent-audit.service.js +4 -2
  54. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  55. package/dist/modules/auth/account-admission.d.ts +58 -7
  56. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  57. package/dist/modules/auth/account-admission.js +44 -1
  58. package/dist/modules/auth/account-admission.js.map +1 -1
  59. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  60. package/dist/modules/auth/account-erasure.service.js +57 -16
  61. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  62. package/dist/modules/auth/account.routes.d.ts +1 -1
  63. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  64. package/dist/modules/auth/account.routes.js +61 -5
  65. package/dist/modules/auth/account.routes.js.map +1 -1
  66. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  67. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  68. package/dist/modules/auth/auth.middleware.js +18 -7
  69. package/dist/modules/auth/auth.middleware.js.map +1 -1
  70. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  71. package/dist/modules/auth/auth.routes.js +8 -0
  72. package/dist/modules/auth/auth.routes.js.map +1 -1
  73. package/dist/modules/auth/auth.service.d.ts +70 -2
  74. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  75. package/dist/modules/auth/auth.service.js +197 -18
  76. package/dist/modules/auth/auth.service.js.map +1 -1
  77. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  78. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  79. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  80. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  81. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  82. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  83. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  84. package/dist/modules/database/connection.d.ts +16 -0
  85. package/dist/modules/database/connection.d.ts.map +1 -1
  86. package/dist/modules/database/connection.js +117 -0
  87. package/dist/modules/database/connection.js.map +1 -1
  88. package/dist/modules/database/core-schema.d.ts +337 -120
  89. package/dist/modules/database/core-schema.d.ts.map +1 -1
  90. package/dist/modules/database/core-schema.js +116 -58
  91. package/dist/modules/database/core-schema.js.map +1 -1
  92. package/dist/modules/database/migrate.d.ts +8 -0
  93. package/dist/modules/database/migrate.d.ts.map +1 -1
  94. package/dist/modules/database/migrate.js +271 -1
  95. package/dist/modules/database/migrate.js.map +1 -1
  96. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  97. package/dist/modules/kb-fs/branch-name.js +12 -2
  98. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  99. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  100. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  101. package/dist/modules/kb-fs/repo-path.js +79 -0
  102. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  104. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  105. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  106. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  108. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js +1 -1
  110. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  112. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  113. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  114. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  115. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  116. package/dist/modules/mcp/mcp.service.js +38 -8
  117. package/dist/modules/mcp/mcp.service.js.map +1 -1
  118. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  119. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  120. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  121. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-request-records.store.js +7 -4
  123. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  124. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  125. package/dist/modules/settings/deployment-settings.service.js +8 -3
  126. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  127. package/dist/modules/settings/setup.routes.d.ts +52 -1
  128. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  129. package/dist/modules/settings/setup.routes.js +207 -19
  130. package/dist/modules/settings/setup.routes.js.map +1 -1
  131. package/dist/modules/skills/skills.contract.d.ts +90 -18
  132. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  133. package/dist/modules/skills/skills.contract.js +4 -1
  134. package/dist/modules/skills/skills.contract.js.map +1 -1
  135. package/dist/modules/skills/skills.service.d.ts +98 -9
  136. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  137. package/dist/modules/skills/skills.service.js +239 -37
  138. package/dist/modules/skills/skills.service.js.map +1 -1
  139. package/dist/modules/skills/skills.tools.d.ts +7 -0
  140. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  141. package/dist/modules/skills/skills.tools.js +71 -7
  142. package/dist/modules/skills/skills.tools.js.map +1 -1
  143. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  144. package/dist/modules/tool-auth/external-api-key.service.js +12 -4
  145. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  146. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  147. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  148. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  149. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  150. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  151. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  152. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  153. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  154. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  155. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  156. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  157. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  158. package/dist/modules/tool-helpers/validate-token.js +1 -1
  159. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  160. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  161. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  162. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  163. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  164. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  165. package/dist/modules/tool-registry/description-length.js +108 -0
  166. package/dist/modules/tool-registry/description-length.js.map +1 -0
  167. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  168. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  169. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  170. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  171. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  172. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  173. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  174. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  175. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  176. package/dist/modules/workflow/file-lock.service.js +30 -0
  177. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  178. package/dist/modules/workflow/git/git.service.d.ts +94 -0
  179. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  180. package/dist/modules/workflow/git/git.service.js +193 -3
  181. package/dist/modules/workflow/git/git.service.js.map +1 -1
  182. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  183. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  184. package/dist/modules/workflow/pending-commits.service.js +60 -1
  185. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  186. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  187. package/dist/modules/workflow/recovery-bot.js +7 -3
  188. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  189. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  190. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  191. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  192. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  193. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  194. package/dist/modules/workflow/workflow-hooks.js +16 -1
  195. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  196. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  197. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  198. package/dist/modules/workflow/workflow.service.js +115 -5
  199. package/dist/modules/workflow/workflow.service.js.map +1 -1
  200. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  201. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  202. package/dist/modules/workspace/agent-access.gate.js +123 -0
  203. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  204. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  205. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  206. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  207. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  208. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  209. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  210. package/dist/modules/workspace/agent-upload.store.js +553 -0
  211. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  212. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  213. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  214. package/dist/modules/workspace/routine-write-policy.js +5 -6
  215. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  216. package/dist/modules/workspace/session-sink.d.ts +5 -5
  217. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  218. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  219. package/dist/modules/workspace/set-aside-clone.js +92 -0
  220. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  221. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  222. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  223. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  224. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  225. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  226. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  227. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  228. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  229. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  231. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  232. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  233. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  234. package/dist/modules/workspace/upload-limits.js +13 -0
  235. package/dist/modules/workspace/upload-limits.js.map +1 -0
  236. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  237. package/dist/modules/workspace/workspace.routes.js +93 -4
  238. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  239. package/dist/modules/workspace/workspace.service.d.ts +128 -5
  240. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  241. package/dist/modules/workspace/workspace.service.js +316 -58
  242. package/dist/modules/workspace/workspace.service.js.map +1 -1
  243. package/dist/modules/workspace/workspace.tools.d.ts +13 -11
  244. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  245. package/dist/modules/workspace/workspace.tools.js +791 -198
  246. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  247. package/dist/modules/workspace/write-denial.d.ts +0 -6
  248. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  249. package/dist/modules/workspace/write-denial.js +0 -6
  250. package/dist/modules/workspace/write-denial.js.map +1 -1
  251. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  252. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  253. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  254. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  255. package/dist/modules/write-access/write-access.d.ts +60 -0
  256. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  257. package/dist/modules/write-access/write-access.js +129 -0
  258. package/dist/modules/write-access/write-access.js.map +1 -0
  259. package/dist/shared/column-crypto.d.ts +194 -0
  260. package/dist/shared/column-crypto.d.ts.map +1 -0
  261. package/dist/shared/column-crypto.js +144 -0
  262. package/dist/shared/column-crypto.js.map +1 -0
  263. package/dist/shared/domain-errors.d.ts +25 -0
  264. package/dist/shared/domain-errors.d.ts.map +1 -1
  265. package/dist/shared/domain-errors.js +28 -0
  266. package/dist/shared/domain-errors.js.map +1 -1
  267. package/dist/shared/git.contract.d.ts +20 -0
  268. package/dist/shared/git.contract.d.ts.map +1 -1
  269. package/dist/shared/git.contract.js +26 -0
  270. package/dist/shared/git.contract.js.map +1 -1
  271. package/dist/shared/token-crypto.d.ts.map +1 -1
  272. package/dist/shared/token-crypto.js +25 -1
  273. package/dist/shared/token-crypto.js.map +1 -1
  274. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  275. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  276. package/dist/tenancy/static-tenant-source.js +1 -2
  277. package/dist/tenancy/static-tenant-source.js.map +1 -1
  278. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  279. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  280. package/dist/tenancy/tenant-secrets.js +4 -0
  281. package/dist/tenancy/tenant-secrets.js.map +1 -1
  282. package/kb-template/AGENTS.md +2 -0
  283. package/migrations/0014_change_request_closed_reason.sql +1 -0
  284. package/migrations/0015_account_deactivation.sql +3 -0
  285. package/migrations/0016_pii_encryption.sql +20 -0
  286. package/migrations/meta/0014_snapshot.json +2265 -0
  287. package/migrations/meta/0015_snapshot.json +2271 -0
  288. package/migrations/meta/0016_snapshot.json +2327 -0
  289. package/migrations/meta/_journal.json +21 -0
  290. package/package.json +3 -3
  291. package/src/__tests__/kb-layout-config.test.ts +0 -2
  292. package/src/__tests__/retired-settings.test.ts +96 -0
  293. package/src/core/__tests__/gated-boot.test.ts +132 -0
  294. package/src/core/__tests__/lifecycle.test.ts +241 -5
  295. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  296. package/src/core/core-ports.ts +11 -1
  297. package/src/core/create-core-server.ts +52 -16
  298. package/src/core/create-core-services.ts +156 -24
  299. package/src/core/lifecycle.ts +117 -13
  300. package/src/core-config.ts +31 -14
  301. package/src/index.ts +30 -1
  302. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  303. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  304. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  305. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  306. package/src/modules/access/access-control.interface.ts +43 -12
  307. package/src/modules/access/access-control.service.ts +234 -36
  308. package/src/modules/access/access.routes.ts +17 -16
  309. package/src/modules/access/directory-sync-bot.ts +7 -3
  310. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
  311. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  312. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  313. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  314. package/src/modules/agent-instructions/compose.ts +50 -7
  315. package/src/modules/agent-instructions/index.ts +12 -0
  316. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  317. package/src/modules/audit/agent-audit.service.ts +4 -2
  318. package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
  319. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  320. package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
  321. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  322. package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
  323. package/src/modules/auth/account-admission.ts +78 -9
  324. package/src/modules/auth/account-erasure.service.ts +69 -18
  325. package/src/modules/auth/account.routes.ts +63 -7
  326. package/src/modules/auth/auth.middleware.ts +19 -8
  327. package/src/modules/auth/auth.routes.ts +8 -0
  328. package/src/modules/auth/auth.service.ts +206 -23
  329. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  330. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  331. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  332. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  333. package/src/modules/database/__tests__/connection.test.ts +12 -0
  334. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  335. package/src/modules/database/connection.ts +117 -0
  336. package/src/modules/database/core-schema.ts +116 -58
  337. package/src/modules/database/migrate.ts +353 -1
  338. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  339. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  340. package/src/modules/kb-fs/branch-name.ts +14 -1
  341. package/src/modules/kb-fs/repo-path.ts +85 -0
  342. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  343. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  344. package/src/modules/kb-sync/sync-auth.ts +2 -2
  345. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  346. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  347. package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
  348. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  349. package/src/modules/mcp/mcp.service.ts +46 -7
  350. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  351. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  352. package/src/modules/plugins/join-request-records.store.ts +7 -4
  353. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  354. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  355. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  356. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  357. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  358. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  359. package/src/modules/settings/deployment-settings.service.ts +8 -3
  360. package/src/modules/settings/setup.routes.ts +263 -19
  361. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  362. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  363. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  364. package/src/modules/skills/skills.contract.ts +91 -18
  365. package/src/modules/skills/skills.service.ts +278 -41
  366. package/src/modules/skills/skills.tools.ts +80 -8
  367. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  368. package/src/modules/tool-auth/external-api-key.service.ts +12 -4
  369. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  370. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  371. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  372. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  373. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  374. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  375. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  376. package/src/modules/tool-helpers/validate-token.ts +1 -1
  377. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  378. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  379. package/src/modules/tool-registry/description-length.ts +111 -0
  380. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  381. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  382. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  383. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  384. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  385. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  386. package/src/modules/workflow/file-lock.service.ts +31 -0
  387. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  388. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  389. package/src/modules/workflow/git/git.service.ts +215 -1
  390. package/src/modules/workflow/pending-commits.service.ts +64 -2
  391. package/src/modules/workflow/recovery-bot.ts +7 -3
  392. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  393. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  394. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  395. package/src/modules/workflow/workflow-hooks.ts +64 -26
  396. package/src/modules/workflow/workflow.service.ts +125 -5
  397. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  398. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  399. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -0
  400. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  401. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  402. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  403. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  404. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  405. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  406. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  407. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +402 -0
  408. package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
  409. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
  410. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  411. package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
  412. package/src/modules/workspace/agent-access.gate.ts +164 -0
  413. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  414. package/src/modules/workspace/agent-upload.store.ts +668 -0
  415. package/src/modules/workspace/routine-write-policy.ts +5 -6
  416. package/src/modules/workspace/session-sink.ts +5 -5
  417. package/src/modules/workspace/set-aside-clone.ts +96 -0
  418. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  419. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  420. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  421. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  422. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  423. package/src/modules/workspace/upload-limits.ts +12 -0
  424. package/src/modules/workspace/workspace.routes.ts +96 -5
  425. package/src/modules/workspace/workspace.service.ts +319 -61
  426. package/src/modules/workspace/workspace.tools.ts +894 -214
  427. package/src/modules/workspace/write-denial.ts +0 -8
  428. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  429. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  430. package/src/modules/write-access/write-access.ts +153 -0
  431. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  432. package/src/shared/column-crypto.ts +218 -0
  433. package/src/shared/domain-errors.ts +31 -0
  434. package/src/shared/git.contract.ts +27 -0
  435. package/src/shared/token-crypto.ts +28 -1
  436. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
  437. package/src/tenancy/static-tenant-source.ts +1 -3
  438. package/src/tenancy/tenant-secrets.ts +5 -1
  439. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  440. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  441. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  442. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  443. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  444. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  445. package/dist/modules/workflow/session-ontology.service.js +0 -147
  446. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  447. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  448. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  449. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  450. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  451. package/dist/shared/kb-layout.d.ts +0 -39
  452. package/dist/shared/kb-layout.d.ts.map +0 -1
  453. package/dist/shared/kb-layout.js +0 -103
  454. package/dist/shared/kb-layout.js.map +0 -1
  455. package/dist/shared/kb-layout.test.d.ts +0 -2
  456. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  457. package/dist/shared/kb-layout.test.js +0 -75
  458. package/dist/shared/kb-layout.test.js.map +0 -1
  459. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  460. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  461. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  462. package/src/modules/workflow/session-ontology.service.ts +0 -183
  463. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  464. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  465. package/src/shared/kb-layout.test.ts +0 -98
  466. package/src/shared/kb-layout.ts +0 -102
@@ -11,22 +11,27 @@ import { testKbContext } from '../../../__tests__/kb-context.js';
11
11
  import { LocalFilesystem } from '@mastra/core/workspace';
12
12
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
13
13
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
14
- import type { ToolContext } from '../../tool-helpers/tool.contract.js';
14
+ import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
15
15
  import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
16
- import { CONTENT_RULE, registerWorkspaceTools } from '../workspace.tools.js';
16
+ import { registerWorkspaceTools } from '../workspace.tools.js';
17
+ import { sharedFileRules, sharedFileRulesSection, sharedRulesPointer } from '../../agent-instructions/shared-file-rules.js';
17
18
  import { RoutineWritePolicyService } from '../routine-write-policy.js';
18
19
  import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
19
- import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
20
+ import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
21
+ import { SESSION_ID_DESCRIPTION, ToolDescriptionNotes } from '../agent-access.gate.js';
20
22
  import { SpillStore } from '../spill-store.js';
21
23
  import { DocExtractService } from '../file-readers/doc-extract.service.js';
22
24
  import { OCTET_STREAM_FALLBACK_NOTE } from '../file-readers/content-mode.js';
23
25
  import type { IAccessControl } from '../../access/access-control.interface.js';
26
+ import { AccessControlService } from '../../access/access-control.service.js';
27
+ import { NodeFs } from '../../kb-fs/node-fs.js';
28
+ import type { WorkspaceService } from '../workspace.service.js';
24
29
  import { isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
25
30
  import { assertValidBranchName } from '../../kb-fs/branch-name.js';
26
31
  import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
27
32
  import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
28
33
  import { AccessDeniedError } from '../../access-model/access-errors.js';
29
- import { PROPOSAL_ROUTE_NOTE, proposalTitleFor } from '../write-denial.js';
34
+ import { proposalTitleFor } from '../write-denial.js';
30
35
  import { NOT_FOUND_NEXT_STEP } from '../not-found.js';
31
36
 
32
37
  const KB_DIR = 'knowledge-base';
@@ -43,6 +48,11 @@ const allowAll = {
43
48
  canDownload: async () => true,
44
49
  canOwner: async () => true,
45
50
  canWriteBatchAtRef: async () => null,
51
+ // The `after` half of a move's or copy's preview. Allowing everything here
52
+ // keeps it the same answer as the four gates above; what the relocation of
53
+ // a folder's `access.md` files actually does to it is exercised against
54
+ // REAL rules in "a move preview judges the destination as it will be".
55
+ previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
46
56
  } as unknown as IAccessControl;
47
57
 
48
58
  /** Access control that denies `canRead` for an explicit set of repo-relative paths. */
@@ -76,6 +86,14 @@ let fs: LocalFilesystem;
76
86
  let workspacePathCalls: string[] = [];
77
87
  /** Every `(archive, destination)` pair `unzip` handed the service. */
78
88
  let unzipCalls: [string, string | undefined][] = [];
89
+ /**
90
+ * Workspace-relative targets the `unzipFile` stand-in pretends the archive
91
+ * holds. Empty by default (the stub extracts nothing); a test that is about
92
+ * the per-entry write guard sets it, and the stand-in then runs the guard once
93
+ * per entry the way the real service does — which is asserted where it lives,
94
+ * in `workspace.service.test.ts`.
95
+ */
96
+ let unzipEntries: string[] = [];
79
97
  /** Every folder turn a tool took, as `workspaceId:dir`. */
80
98
  let folderTurns: string[] = [];
81
99
  /** The policy instance the tools were mounted with, so a test can restrict a session. */
@@ -88,6 +106,21 @@ let writePolicy: RoutineWritePolicyService;
88
106
  let focusedBranch: string | undefined;
89
107
  /** The registry the tools were mounted into, so a test can inspect their defs. */
90
108
  let toolRegistry: ToolRegistry;
109
+ /** The hook registry the tools were mounted against, so a test can register one. */
110
+ let hooks: WorkflowHooks;
111
+ /** The note registry the tools were mounted against, so a test can register a note. */
112
+ let notes: ToolDescriptionNotes;
113
+ /**
114
+ * The recovery/merge bot's address, as the mounted gate knows it — the one
115
+ * identity that never reaches a hook.
116
+ */
117
+ const RECOVERY_BOT = 'recovery-bot@bevel.local';
118
+ /**
119
+ * What the auth layer resolves this caller's source to. `internal` (the
120
+ * in-process agent) unless a test is about a person in the app (`session`) or
121
+ * an external agent.
122
+ */
123
+ let callerSource: 'internal' | 'external' | 'session' = 'internal';
91
124
  /**
92
125
  * Another writer getting to a path in the window the real `LockingFilesystem`
93
126
  * closes: the stand-ins below run it where that filesystem would be acquiring
@@ -103,6 +136,9 @@ async function start(
103
136
  userEmail = 'e@x',
104
137
  ): Promise<string> {
105
138
  raceHook = null;
139
+ hooks = new WorkflowHooks();
140
+ notes = new ToolDescriptionNotes();
141
+ callerSource = 'internal';
106
142
  tempDir = await mkdtemp(join(tmpdir(), 'ws-tools-'));
107
143
  docCacheDir = await mkdtemp(join(tmpdir(), 'ws-doc-cache-'));
108
144
  fs = new LocalFilesystem({ basePath: tempDir, contained: true });
@@ -163,6 +199,7 @@ async function start(
163
199
  await fs.writeFile('a.md', 'hello\nworld\n');
164
200
  workspacePathCalls = [];
165
201
  unzipCalls = [];
202
+ unzipEntries = [];
166
203
  folderTurns = [];
167
204
  writePolicy = new RoutineWritePolicyService();
168
205
  focusedBranch = undefined;
@@ -195,7 +232,12 @@ async function start(
195
232
  // shapes is absence, and the real service raises exactly this error for
196
233
  // it (asserted in workspace.service.test.ts). Extraction itself lives
197
234
  // there too — none of it is the tool's to decide.
198
- unzipFile: async (_id: string, zipRel: string, destRel?: string) => {
235
+ unzipFile: async (
236
+ _id: string,
237
+ zipRel: string,
238
+ destRel?: string,
239
+ guardWrite?: (wsRelativePath: string) => Promise<void>,
240
+ ) => {
199
241
  // Recorded so a test can assert the PATHS the tool handed over — both
200
242
  // ends normalised into the repository.
201
243
  unzipCalls.push([zipRel, destRel]);
@@ -204,7 +246,17 @@ async function start(
204
246
  } catch {
205
247
  throw new PathNotFoundError(zipRel);
206
248
  }
207
- return { destination: '', extracted: [], skipped: [] };
249
+ const extracted: string[] = [];
250
+ const skipped: { path: string; reason: string }[] = [];
251
+ for (const entry of unzipEntries) {
252
+ try {
253
+ await guardWrite?.(entry);
254
+ extracted.push(entry);
255
+ } catch (err) {
256
+ skipped.push({ path: entry, reason: err instanceof Error ? err.message : 'refused' });
257
+ }
258
+ }
259
+ return { destination: '', extracted, skipped };
208
260
  },
209
261
  } as never,
210
262
  workflowService: {} as never,
@@ -213,18 +265,19 @@ async function start(
213
265
  });
214
266
  const toolHandler = createToolHandlerFactory(resolve);
215
267
  const fakeAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
216
- req.toolAuth = { source: 'internal', userId: 'u', scope };
268
+ req.toolAuth = { source: callerSource, userId: 'u', scope };
217
269
  next();
218
270
  };
219
271
  const app = express();
220
272
  app.use(express.json());
221
273
  const router = express.Router();
222
274
  registerWorkspaceTools(registry, router, fakeAuth, toolHandler, new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(docCacheDir), access, testKbContext({ kbDirName: KB_DIR }), {
223
- service: {} as never,
224
- enabled: false, // these tests predate and don't exercise the ontology boundary
225
- kb: testKbContext({ kbDirName: KB_DIR }),
226
- recoveryBotEmail: 'recovery-bot@bevel.local',
227
- hooks: new WorkflowHooks(),
275
+ // The hooks and the notes a test registers against; with neither
276
+ // registered (the default, and every Hexis-only deployment) the gate
277
+ // refuses nothing and the descriptions say nothing extra.
278
+ recoveryBotEmail: RECOVERY_BOT,
279
+ hooks,
280
+ notes,
228
281
  }, writePolicy, {} as never /* sessionSink — start_session not exercised here */);
229
282
  app.use('/api', router);
230
283
  httpServer = await new Promise<HttpServer>((r) => {
@@ -868,19 +921,26 @@ describe('write modes and per-path outcomes', () => {
868
921
  });
869
922
  });
870
923
 
871
- it('both descriptions state the default and all three modes, and `mode` is an input on each', async () => {
924
+ it('states the three modes on the `mode` input of each write tool, and once in the shared rules', async () => {
872
925
  await start();
873
926
  const tools = await toolRegistry.listInternal();
927
+ // The paragraph that said this in BOTH descriptions is one shared rule now.
928
+ // What stays on the tool is the input the agent fills, which is where the
929
+ // decision is actually taken.
930
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'write-mode')!;
931
+ expect(rule.body).toContain('DEFAULTS TO `create`');
932
+ expect(rule.body).toContain('On write_file and write_files');
933
+ expect(sharedFileRulesSection(testKbContext().layout).split(rule.body)).toHaveLength(2);
874
934
  for (const name of ['write_file', 'write_files']) {
875
935
  const def = tools.find((t) => t.name === name)!;
876
- expect(def.description, name).toContain('DEFAULTS TO `create`');
877
- for (const mode of ['`create`', '`overwrite`', '`update`']) {
878
- expect(def.description, `${name} ${mode}`).toContain(mode);
879
- }
936
+ expect(def.description, name).not.toContain('DEFAULTS TO `create`');
880
937
  const body = (def.inputs as { properties: { body: { properties: Record<string, { enum?: string[]; description?: string }> } } }).properties.body;
881
938
  expect(body.properties.mode, name).toBeDefined();
882
939
  expect(body.properties.mode.enum, name).toEqual(['create', 'overwrite', 'update']);
883
940
  expect(body.properties.mode.description, name).toContain('default `create`');
941
+ for (const mode of ['`create`', '`overwrite`', '`update`']) {
942
+ expect(body.properties.mode.description, `${name} ${mode}`).toContain(mode);
943
+ }
884
944
  }
885
945
  });
886
946
 
@@ -1543,28 +1603,36 @@ describe('office documents and PDFs', () => {
1543
1603
  expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
1544
1604
  });
1545
1605
 
1546
- it('every file tool states the SAME content rule — refused families, the byte tools and the upload path — so agents learn before the call', async () => {
1606
+ it('every mounted tool ends with the one sentence pointing at the shared rules, and repeats none of them', async () => {
1547
1607
  await start();
1548
1608
  const tools = await toolRegistry.listInternal();
1549
- const fileTools = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'mkdir', 'move_file', 'copy_file', 'unzip'];
1550
- for (const name of fileTools) {
1609
+ const pointer = sharedRulesPointer(testKbContext().layout);
1610
+ // The shell is in the list too: it carried the agent-guide reminder before,
1611
+ // and that reminder is one of the rules that moved.
1612
+ const mounted = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip', 'execute_command'];
1613
+ for (const name of mounted) {
1551
1614
  const def = tools.find((t) => t.name === name);
1552
1615
  expect(def, name).toBeDefined();
1553
- // One constant, verbatim — the description is what tools_info returns.
1554
- expect(def!.description, name).toContain(CONTENT_RULE);
1555
- expect(def!.description, name).toContain('`binary_not_writable`');
1556
- expect(def!.description, name).toContain('copy_file, move_file, delete_file and unzip act on bytes of any kind');
1557
- expect(def!.description, name).toContain('`request_upload_token` + `apply_upload`');
1558
- expect(def!.description, name).toContain('`contentMode`');
1559
- // Modern extractable formats…
1560
- expect(def!.description, name).toContain('.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf');
1561
- // …email files (extractions too, so the same refusal applies)…
1562
- expect(def!.description, name).toContain('.eml/.msg');
1563
- // …the legacy binary family the refusal also covers…
1564
- expect(def!.description, name).toContain('.doc/.ppt/.xls');
1616
+ expect(def!.description!.endsWith(pointer), name).toBe(true);
1617
+ // Once, at the end — not once per paragraph that used to be appended.
1618
+ expect(def!.description!.split(pointer), name).toHaveLength(2);
1619
+ // EVERY shared rule, in full, is in the two shared places now (see
1620
+ // agent-instructions/__tests__/shared-file-rules.test.ts) and in no
1621
+ // description. Checked on the whole body rather than on a phrase: the
1622
+ // drift this PR exists to prevent is a paragraph pasted back onto a
1623
+ // tool, and naming only two marker phrases would catch two of eight.
1624
+ for (const rule of sharedFileRules(testKbContext().layout)) {
1625
+ expect(def!.description, `${name} / ${rule.id}`).not.toContain(rule.body);
1626
+ }
1627
+ // The two lead-in labels the paragraphs used to arrive under are gone
1628
+ // with them — a description carrying one is carrying the old text.
1629
+ expect(def!.description, name).not.toContain('Content rule (the same on every file tool)');
1630
+ expect(def!.description, name).not.toContain('Before your first read or change in a workspace');
1565
1631
  }
1566
- // The shell is not a file tool: it does not carry the rule.
1567
- expect(tools.find((t) => t.name === 'execute_command')!.description).not.toContain(CONTENT_RULE);
1632
+ // start_session carried none of the shared paragraphs and gains no pointer:
1633
+ // it touches no file. External-only, so it is looked up on that surface.
1634
+ const external = await toolRegistry.listExternal();
1635
+ expect(external.find((t) => t.name === 'start_session')!.description).not.toContain(pointer);
1568
1636
  });
1569
1637
 
1570
1638
  describe('binary capability contract: a text file, a document, an image and a zip', () => {
@@ -1710,14 +1778,15 @@ describe('office documents and PDFs', () => {
1710
1778
  });
1711
1779
  });
1712
1780
 
1713
- it('the page-writing tools say where images go, so an agent writes the link a page will render', async () => {
1781
+ it('says where the images a page uses go — once, in the shared rules', async () => {
1714
1782
  await start();
1715
1783
  const tools = await toolRegistry.listInternal();
1784
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'images-in-pages')!;
1785
+ expect(rule.body).toContain('`assets/` folder next to the page');
1786
+ expect(rule.body).toContain('![Approval screen](./assets/approval-screen.png)');
1787
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
1716
1788
  for (const name of ['write_file', 'write_files']) {
1717
- const def = tools.find((t) => t.name === name);
1718
- expect(def, name).toBeDefined();
1719
- expect(def!.description, name).toContain('`assets/` folder next to the page');
1720
- expect(def!.description, name).toContain('![Approval screen](./assets/approval-screen.png)');
1789
+ expect(tools.find((t) => t.name === name)!.description, name).not.toContain('`assets/` folder next to the page');
1721
1790
  }
1722
1791
  });
1723
1792
  });
@@ -1816,7 +1885,7 @@ describe('images', () => {
1816
1885
  /**
1817
1886
  * start_session must mint a REAL chat thread and return its id (not a bare
1818
1887
  * random UUID), so the same id works for KB reads AND for `ask` (whose
1819
- * sessionId IS a chat thread). This is what unifies the ontology boundary
1888
+ * sessionId IS a chat thread). This is what unifies the conversation
1820
1889
  * across reads + ask — see workspace.tools.ts start_session comment.
1821
1890
  */
1822
1891
  describe('start_session', () => {
@@ -1863,7 +1932,7 @@ describe('start_session', () => {
1863
1932
  registerWorkspaceTools(
1864
1933
  registry, router, auth, toolHandler,
1865
1934
  new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
1866
- { service: {} as never, enabled: false, kb: testKbContext({ kbDirName: KB_DIR }), recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
1935
+ { recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
1867
1936
  new RoutineWritePolicyService(),
1868
1937
  sink ?? fakeSessionSink,
1869
1938
  );
@@ -1988,7 +2057,7 @@ describe('start_session', () => {
1988
2057
  registerWorkspaceTools(
1989
2058
  registry, router, noopAuth, (() => () => {}) as never,
1990
2059
  new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
1991
- { service: {} as never, enabled: false, kb: testKbContext({ kbDirName: KB_DIR }), recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
2060
+ { recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
1992
2061
  new RoutineWritePolicyService(),
1993
2062
  {} as never,
1994
2063
  );
@@ -2030,7 +2099,7 @@ describe('branch is a required parameter in the tool contract', () => {
2030
2099
  new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')),
2031
2100
  allowAll,
2032
2101
  testKbContext({ kbDirName: KB_DIR }),
2033
- { service: {} as never, enabled: false, kb: testKbContext({ kbDirName: KB_DIR }), recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
2102
+ { recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
2034
2103
  new RoutineWritePolicyService(),
2035
2104
  {} as never,
2036
2105
  );
@@ -2514,6 +2583,11 @@ describe('preflight for moves and deletes', () => {
2514
2583
  canWriteBatchAtRef: async (_w: string, _r: string, _u: string, rels: string[]) =>
2515
2584
  new Map(rels.map((p) => [p, verbsFor(p).write])),
2516
2585
  eligibleWritersAtRef: async () => ({ roles: ['Admin'], users: [] }),
2586
+ // This double has no access files to carry, so the destination as it will
2587
+ // be IS the destination as it is — the answer `accessAt` gave before the
2588
+ // preview learned to relocate them, which keeps every ordering and
2589
+ // oracle test below about what it is about.
2590
+ previewAccessAfterRelocation: async (_w: string, _u: string, _from: string, to: string) => verbsFor(to),
2517
2591
  } as unknown as IAccessControl;
2518
2592
 
2519
2593
  const call = async (base: string, tool: string, body: Record<string, unknown>) => {
@@ -2657,6 +2731,35 @@ describe('preflight for moves and deletes', () => {
2657
2731
  expect(await exists(KB('Sales/deal.md'))).toBe(true);
2658
2732
  });
2659
2733
 
2734
+ /**
2735
+ * The dry run keeps that order too, and it is the easier oracle to reach:
2736
+ * it answers 200 rather than throwing, so a denied caller could read the
2737
+ * source's kind and its file count off it — or a 404 saying whether a
2738
+ * source they may not copy from exists at all. Nothing on disk is probed
2739
+ * until the write verdict on the destination has been taken.
2740
+ */
2741
+ it('copy_file\'s dry run refuses a denied destination the same way whatever the source is', async () => {
2742
+ const base = await seeded();
2743
+ const dry = async (src: string) =>
2744
+ (await call(base, 'copy_file', { src, dest: KB('Locked/new.md'), dryRun: true })).body;
2745
+
2746
+ const file = await dry(KB('Sales/deal.md'));
2747
+ const folder = await dry(KB('Sales/archive'));
2748
+ const missing = await dry(KB('Sales/nothing-here.md'));
2749
+
2750
+ // One sentence, three sources: the refusal says nothing about any of them.
2751
+ for (const answer of [file, folder, missing]) {
2752
+ expect(answer).toMatchObject({ allowed: false, dryRun: true, copied: false });
2753
+ expect(answer.reason).toContain(KB('Locked/new.md'));
2754
+ expect(answer.kind).toBeUndefined();
2755
+ expect(answer.descendants).toBeUndefined();
2756
+ }
2757
+ expect(folder.reason).toBe(file.reason);
2758
+ expect(missing.reason).toBe(file.reason);
2759
+ // And the caller's own verbs are still answered — those are theirs.
2760
+ expect(Object.keys(file.access).sort()).toEqual(['after', 'before']);
2761
+ });
2762
+
2660
2763
  it('copy_file keeps the same order: the write refusal, not what is in the folder', async () => {
2661
2764
  const base = await seeded();
2662
2765
 
@@ -3227,8 +3330,12 @@ describe('preflight for moves and deletes', () => {
3227
3330
  expect(d.description).toMatch(/FILE or FOLDER/);
3228
3331
  expect(d.description).toMatch(/folder moves recursively/);
3229
3332
  expect(d.description).toMatch(/destination must not exist/);
3230
- expect(d.description).toContain('is a platform file and stays in its folder.');
3231
- expect(d.description).toContain('`dryRun: true`');
3333
+ // What refuses a move, and the dry-run/confirm protocol it shares with the
3334
+ // deletes, are shared rules — stated once, in the two shared places.
3335
+ const rules = sharedFileRulesSection(testKbContext().layout);
3336
+ expect(rules).toContain('is a platform file and stays in its folder.');
3337
+ expect(rules).toContain('`dryRun: true`');
3338
+ expect(rules).toContain('`confirm: true`');
3232
3339
  expect(d.description).toContain('`confirm: true`');
3233
3340
  expect(Object.keys(d.inputs.properties.body.properties)).toEqual(expect.arrayContaining(['dryRun', 'confirm']));
3234
3341
  // What the description promises a dry run returns is what it returns.
@@ -3238,7 +3345,10 @@ describe('preflight for moves and deletes', () => {
3238
3345
  expect(dry.body, key).toHaveProperty(key);
3239
3346
  }
3240
3347
  const unconfirmed = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') });
3241
- expect(d.description).toContain('confirmationRequired: true');
3348
+ // What an unconfirmed call answers is the shared protocol's promise; the
3349
+ // field is still declared in this tool's own `outputs`.
3350
+ expect(rules).toContain('confirmationRequired: true');
3351
+ expect(declaredOutputs(d)).toContain('confirmationRequired');
3242
3352
  expect(unconfirmed.body.confirmationRequired).toBe(true);
3243
3353
  for (const body of [dry.body, unconfirmed.body]) {
3244
3354
  expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
@@ -3248,9 +3358,12 @@ describe('preflight for moves and deletes', () => {
3248
3358
  it('delete_folder states the confirm rule and refusals, and declares every field it returns', async () => {
3249
3359
  const base = await seeded();
3250
3360
  const d = await def('delete_folder');
3251
- expect(d.description).toContain('`dryRun: true`');
3252
- expect(d.description).toContain('A non-empty folder is deleted only with `confirm: true`');
3253
- expect(d.description).toContain('platform folder');
3361
+ expect(d.description).toContain('a non-empty folder wants `confirm: true`');
3362
+ // The protocol itself, and what a platform folder does to it, are shared.
3363
+ const rules = sharedFileRulesSection(testKbContext().layout);
3364
+ expect(rules).toContain('`dryRun: true`');
3365
+ expect(rules).toContain('A non-empty folder is deleted, and a move that changes your access runs, only with `confirm: true`');
3366
+ expect(rules).toContain('platform folder');
3254
3367
  const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
3255
3368
  const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
3256
3369
  const confirmed = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
@@ -3268,8 +3381,12 @@ describe('preflight for moves and deletes', () => {
3268
3381
  expect(stat.description, key).toContain(`\`${key}`);
3269
3382
  expect(body, key).toHaveProperty(key);
3270
3383
  }
3384
+ expect(stat.description).toContain('shared rules on what these tools never move or delete');
3385
+ // The proposal route applies to every tool a permission can refuse, so it
3386
+ // is stated once in the shared rules rather than on each of them.
3387
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain('`write-denied`');
3271
3388
  for (const name of ['move_file', 'delete_file', 'delete_folder']) {
3272
- expect((await def(name)).description, name).toContain('`write-denied`');
3389
+ expect((await def(name)).description, name).toContain(sharedRulesPointer(testKbContext().layout));
3273
3390
  }
3274
3391
  });
3275
3392
  });
@@ -3310,6 +3427,7 @@ describe('a write refused for permissions says whether and how to propose it', (
3310
3427
  canDownload: async () => true,
3311
3428
  canOwner: async () => true,
3312
3429
  canWriteBatchAtRef: async () => null,
3430
+ previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
3313
3431
  eligibleWritersAtRef: async () => ({ roles: ['Sales Lead'], users: [{ name: 'Owner', email: 'owner@x' }] }),
3314
3432
  } as unknown as IAccessControl;
3315
3433
  return { ac, calls };
@@ -3515,14 +3633,18 @@ describe('a write refused for permissions says whether and how to propose it', (
3515
3633
  expect(res.json).toEqual({ error: 'old_string not found in the file.' });
3516
3634
  });
3517
3635
 
3518
- it('each write tool mentions the proposal route in its description; read tools do not', async () => {
3636
+ it('states the proposal route in the shared rules, not in each write tool description', async () => {
3519
3637
  await start();
3520
3638
  const tools = await toolRegistry.listInternal();
3521
- for (const name of ['write_file', 'edit_file', 'write_files', 'move_file', 'delete_file', 'delete_folder', 'copy_file', 'mkdir']) {
3522
- expect(tools.find((t) => t.name === name)?.description, name).toContain(PROPOSAL_ROUTE_NOTE.trim());
3523
- }
3524
- for (const name of ['read_file', 'grep', 'list_files']) {
3525
- expect(tools.find((t) => t.name === name)?.description, name).not.toContain(PROPOSAL_ROUTE_NOTE.trim());
3639
+ // It applies to every tool a permission can refuse, so it is a shared rule:
3640
+ // stated in the handshake instructions and in the managed guide, and in no
3641
+ // description. The refusal ITSELF still spells the steps out — that is what
3642
+ // the tests above this one assert.
3643
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'refused-for-permissions')!;
3644
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
3645
+ for (const def of tools) {
3646
+ expect(def.description ?? '', def.name).not.toContain('If this is refused for permissions');
3647
+ expect(def.description ?? '', def.name).not.toContain(rule.body);
3526
3648
  }
3527
3649
  });
3528
3650
  });
@@ -3718,3 +3840,591 @@ describe('a path with nothing at it answers 404 not_found on every file tool', (
3718
3840
  }
3719
3841
  });
3720
3842
  });
3843
+
3844
+ /**
3845
+ * The neutral session hooks, through the tool routes.
3846
+ *
3847
+ * Hexis decides nothing about which paths a conversation may touch: it calls a
3848
+ * registered hook before every agent read and every agent write of a
3849
+ * knowledge-base path, hands it what it knows about the call, and lets a hook
3850
+ * that throws refuse the operation with its own message and status. With no
3851
+ * hook registered — every Hexis-only deployment — nothing is refused, nothing
3852
+ * is recorded, and a call without a `sessionId` is an ordinary call.
3853
+ */
3854
+ describe('agent read/write hooks', () => {
3855
+ /** Every call the read hook saw, in order. */
3856
+ let reads: AgentOperationContext[];
3857
+ /** Every call the write hook saw, in order. */
3858
+ let writes: AgentOperationContext[];
3859
+
3860
+ /** Register recording hooks on the mounted registry (after `start()`). */
3861
+ const record = (): void => {
3862
+ reads = [];
3863
+ writes = [];
3864
+ hooks.onAgentRead(async (op) => {
3865
+ reads.push(op);
3866
+ });
3867
+ hooks.onPreWrite(async (op) => {
3868
+ writes.push(op);
3869
+ });
3870
+ };
3871
+
3872
+ /** Register a write hook that refuses `path` (or every path) with a 403. */
3873
+ const refuseWrites = (message: string, path?: string): void => {
3874
+ hooks.onPreWrite(async (op) => {
3875
+ if (path === undefined || op.wsPath === path) throw new ToolError(message, 403);
3876
+ });
3877
+ };
3878
+
3879
+ beforeEach(() => {
3880
+ reads = [];
3881
+ writes = [];
3882
+ });
3883
+
3884
+ it('read_file calls the read hook once with the session, path, branch, user and source', async () => {
3885
+ const base = await start();
3886
+ record();
3887
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
3888
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`, 'plans');
3889
+ const res = await post(`${base}/api/agent/tools/read_file`, {
3890
+ branch: 'draft-1',
3891
+ path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`,
3892
+ sessionId: 's1',
3893
+ });
3894
+ expect(res.status).toBe(200);
3895
+ expect(reads).toEqual([
3896
+ {
3897
+ sessionId: 's1',
3898
+ wsPath: `${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`,
3899
+ branch: 'draft-1',
3900
+ user: { id: 'u', email: 'e@x', name: 'N' },
3901
+ source: 'internal',
3902
+ },
3903
+ ]);
3904
+ expect(writes).toEqual([]);
3905
+ });
3906
+
3907
+ it('the read hook covers list_files, file_stat, grep, delete_file and delete_folder', async () => {
3908
+ const base = await start();
3909
+ record();
3910
+ await fs.mkdir(`${KB_DIR}/Folder`, { recursive: true });
3911
+ await fs.writeFile(`${KB_DIR}/Folder/one.md`, 'needle');
3912
+ await fs.writeFile(`${KB_DIR}/gone.md`, 'x');
3913
+ await post(`${base}/api/agent/tools/list_files`, { path: `${KB_DIR}/Folder`, sessionId: 's1' });
3914
+ await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/Folder/one.md`, sessionId: 's1' });
3915
+ await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: `${KB_DIR}/Folder`, sessionId: 's1' });
3916
+ await post(`${base}/api/agent/tools/delete_file`, { path: `${KB_DIR}/gone.md`, sessionId: 's1' });
3917
+ await post(`${base}/api/agent/tools/delete_folder`, { path: `${KB_DIR}/Folder`, confirm: true, sessionId: 's1' });
3918
+ const paths = reads.map((r) => r.wsPath);
3919
+ expect(paths).toContain(`${KB_DIR}/Folder`);
3920
+ expect(paths).toContain(`${KB_DIR}/Folder/one.md`);
3921
+ expect(paths).toContain(`${KB_DIR}/gone.md`);
3922
+ // A delete carries no bytes from elsewhere, so it is a read, never a write.
3923
+ expect(writes).toEqual([]);
3924
+ });
3925
+
3926
+ it('grep tells the read hook about every file the walk opens, not only its root', async () => {
3927
+ const base = await start();
3928
+ record();
3929
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
3930
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
3931
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'needle');
3932
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'needle');
3933
+ await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: KB_DIR, sessionId: 's1' });
3934
+ const paths = reads.map((r) => r.wsPath);
3935
+ expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`);
3936
+ expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`);
3937
+ });
3938
+
3939
+ it('the write hook covers write_file, edit_file and mkdir, once per path', async () => {
3940
+ const base = await start();
3941
+ record();
3942
+ await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/new.md`, content: 'hi', sessionId: 's1' });
3943
+ await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: 'earth', sessionId: 's1' });
3944
+ await post(`${base}/api/agent/tools/mkdir`, { path: `${KB_DIR}/dir`, sessionId: 's1' });
3945
+ expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/new.md`, `${KB_DIR}/a.md`, `${KB_DIR}/dir`]);
3946
+ expect(writes.every((w) => w.sessionId === 's1' && w.branch === 'main')).toBe(true);
3947
+ });
3948
+
3949
+ it('move_file and copy_file call the write hook for the source path AND the destination', async () => {
3950
+ const base = await start();
3951
+ record();
3952
+ await fs.writeFile(`${KB_DIR}/src.md`, 'body');
3953
+ await fs.writeFile(`${KB_DIR}/copy-me.md`, 'body');
3954
+ await post(`${base}/api/agent/tools/move_file`, { src: `${KB_DIR}/src.md`, dest: `${KB_DIR}/moved.md`, confirm: true, sessionId: 's1' });
3955
+ await post(`${base}/api/agent/tools/copy_file`, { src: `${KB_DIR}/copy-me.md`, dest: `${KB_DIR}/copied.md`, sessionId: 's1' });
3956
+ expect(writes.map((w) => w.wsPath)).toEqual([
3957
+ `${KB_DIR}/src.md`,
3958
+ `${KB_DIR}/moved.md`,
3959
+ `${KB_DIR}/copy-me.md`,
3960
+ `${KB_DIR}/copied.md`,
3961
+ ]);
3962
+ });
3963
+
3964
+ it('unzip reads the archive and calls the write hook once per extracted entry', async () => {
3965
+ const base = await start();
3966
+ record();
3967
+ await fs.writeFile(`${KB_DIR}/a.zip`, 'not-really-a-zip');
3968
+ unzipEntries = [`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`];
3969
+ const res = await post(`${base}/api/agent/tools/unzip`, { path: `${KB_DIR}/a.zip`, sessionId: 's1' });
3970
+ expect(res.status).toBe(200);
3971
+ expect(reads.map((r) => r.wsPath)).toEqual([`${KB_DIR}/a.zip`]);
3972
+ expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`]);
3973
+ });
3974
+
3975
+ it('execute_command calls the write hook once, with no path', async () => {
3976
+ const base = await start();
3977
+ record();
3978
+ await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo hi', sessionId: 's1' });
3979
+ expect(writes).toHaveLength(1);
3980
+ expect(writes[0].wsPath).toBeUndefined();
3981
+ expect(writes[0]).toMatchObject({ sessionId: 's1', branch: 'main', source: 'internal' });
3982
+ expect(reads).toEqual([]);
3983
+ });
3984
+
3985
+ it('a write hook that throws refuses the write with its own message and status, and the file is unchanged', async () => {
3986
+ const base = await start();
3987
+ record();
3988
+ refuseWrites('Not in this conversation.');
3989
+ const res = await post(`${base}/api/agent/tools/write_file`, {
3990
+ path: `${KB_DIR}/a.md`,
3991
+ // `overwrite`, so the unchanged file below is the hook's doing: with no
3992
+ // hook this call WOULD replace `a.md`.
3993
+ mode: 'overwrite',
3994
+ content: 'clobbered',
3995
+ sessionId: 's1',
3996
+ });
3997
+ expect(res.status).toBe(403);
3998
+ expect(((await res.json()) as { error: string }).error).toBe('Not in this conversation.');
3999
+ expect(await readFile(join(tempDir, `${KB_DIR}/a.md`), 'utf8')).toBe('hello\nworld\n');
4000
+ });
4001
+
4002
+ it('a read hook that throws refuses the read with its own message and status', async () => {
4003
+ const base = await start();
4004
+ hooks.onAgentRead(async () => {
4005
+ throw new ToolError('Not in this conversation.', 403);
4006
+ });
4007
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md`, sessionId: 's1' });
4008
+ expect(res.status).toBe(403);
4009
+ expect(((await res.json()) as { error: string }).error).toBe('Not in this conversation.');
4010
+ });
4011
+
4012
+ it('write_files reports the refused path only; the other two land', async () => {
4013
+ const base = await start();
4014
+ record();
4015
+ refuseWrites('Not in this conversation.', `${KB_DIR}/two.md`);
4016
+ const res = await post(`${base}/api/agent/tools/write_files`, {
4017
+ files: [
4018
+ { path: `${KB_DIR}/one.md`, content: '1' },
4019
+ { path: `${KB_DIR}/two.md`, content: '2' },
4020
+ { path: `${KB_DIR}/three.md`, content: '3' },
4021
+ ],
4022
+ sessionId: 's1',
4023
+ });
4024
+ expect(res.status).toBe(200);
4025
+ const body = (await res.json()) as { count: number; files: { path: string; outcome: string; message?: string }[] };
4026
+ expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused', 'created']);
4027
+ expect(body.files[1].message).toBe('Not in this conversation.');
4028
+ expect(body.count).toBe(2);
4029
+ expect(await readFile(join(tempDir, `${KB_DIR}/one.md`), 'utf8')).toBe('1');
4030
+ expect(await readFile(join(tempDir, `${KB_DIR}/three.md`), 'utf8')).toBe('3');
4031
+ });
4032
+
4033
+ it('a write hook that fails unexpectedly fails the whole write_files batch, rather than reading as a refusal', async () => {
4034
+ const base = await start();
4035
+ record();
4036
+ // Not a ToolError: the hook did not JUDGE this path, the gate itself
4037
+ // broke. Reporting that as `refused` would let the other paths commit
4038
+ // past a gate that never ran.
4039
+ hooks.onPreWrite(async (op) => {
4040
+ if (op.wsPath === `${KB_DIR}/two.md`) throw new Error('the hook store is down');
4041
+ });
4042
+ const res = await post(`${base}/api/agent/tools/write_files`, {
4043
+ files: [
4044
+ { path: `${KB_DIR}/one.md`, content: '1' },
4045
+ { path: `${KB_DIR}/two.md`, content: '2' },
4046
+ { path: `${KB_DIR}/three.md`, content: '3' },
4047
+ ],
4048
+ sessionId: 's1',
4049
+ });
4050
+ expect(res.status).toBe(500);
4051
+ for (const name of ['one.md', 'two.md', 'three.md']) {
4052
+ await expect(readFile(join(tempDir, `${KB_DIR}/${name}`), 'utf8')).rejects.toThrow();
4053
+ }
4054
+ });
4055
+
4056
+ it('unzip skips the refused entry with the hook\'s message and extracts the others', async () => {
4057
+ const base = await start();
4058
+ record();
4059
+ await fs.writeFile(`${KB_DIR}/a.zip`, 'not-really-a-zip');
4060
+ unzipEntries = [`${KB_DIR}/out/keep.md`, `${KB_DIR}/out/blocked.md`, `${KB_DIR}/out/also-keep.md`];
4061
+ refuseWrites('Not in this conversation.', `${KB_DIR}/out/blocked.md`);
4062
+ const res = await post(`${base}/api/agent/tools/unzip`, { path: `${KB_DIR}/a.zip`, sessionId: 's1' });
4063
+ const body = (await res.json()) as { extracted: string[]; skipped: { path: string; reason: string }[] };
4064
+ expect(body.extracted).toEqual([`${KB_DIR}/out/keep.md`, `${KB_DIR}/out/also-keep.md`]);
4065
+ expect(body.skipped).toEqual([{ path: `${KB_DIR}/out/blocked.md`, reason: 'Not in this conversation.' }]);
4066
+ });
4067
+
4068
+ it('a hook is called with no session id when the call carried none — what happens next is its decision', async () => {
4069
+ const base = await start();
4070
+ record();
4071
+ await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` });
4072
+ await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x' });
4073
+ expect(reads).toHaveLength(1);
4074
+ expect(reads[0].sessionId).toBeUndefined();
4075
+ expect(writes).toHaveLength(1);
4076
+ expect(writes[0].sessionId).toBeUndefined();
4077
+ });
4078
+
4079
+ it('a person saving in the app never reaches the hooks', async () => {
4080
+ const base = await start();
4081
+ record();
4082
+ callerSource = 'session';
4083
+ await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md`, sessionId: 's1' });
4084
+ await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x', sessionId: 's1' });
4085
+ expect(reads).toEqual([]);
4086
+ expect(writes).toEqual([]);
4087
+ });
4088
+
4089
+ it('the recovery bot never reaches the hooks', async () => {
4090
+ const base = await start('write', allowAll, RECOVERY_BOT);
4091
+ record();
4092
+ refuseWrites('Not in this conversation.');
4093
+ const res = await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x', sessionId: 's1' });
4094
+ expect(res.status).toBe(200);
4095
+ expect(reads).toEqual([]);
4096
+ expect(writes).toEqual([]);
4097
+ });
4098
+
4099
+ /**
4100
+ * The Hexis-only deployment: nothing registered at all. No call is refused,
4101
+ * and a file tool call without a `sessionId` succeeds — including one that
4102
+ * read under two different folders first.
4103
+ */
4104
+ it('with no hook registered, reads and writes without a sessionId all succeed', async () => {
4105
+ const base = await start();
4106
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
4107
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
4108
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'p');
4109
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'l');
4110
+ expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md` })).status).toBe(200);
4111
+ expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md` })).status).toBe(200);
4112
+ const wrote = await post(`${base}/api/agent/tools/write_file`, {
4113
+ path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/New.md`,
4114
+ content: 'fresh',
4115
+ });
4116
+ expect(wrote.status).toBe(200);
4117
+ expect(await readFile(join(tempDir, `${KB_DIR}/KnowledgeBase/Product/Knowledge/New.md`), 'utf8')).toBe('fresh');
4118
+ });
4119
+ });
4120
+
4121
+ /**
4122
+ * What an agent READS about these tools. A Hexis-only deployment names no
4123
+ * boundary and no ontology; a deployment that has one registers the wording
4124
+ * for it, and it lands on the gated tools and on the `sessionId` input.
4125
+ */
4126
+ describe('tool descriptions and the deployment note', () => {
4127
+ /** Every tool def on the external surface, by name. */
4128
+ const defs = async (): Promise<Map<string, { description: string; inputs: unknown }>> =>
4129
+ new Map((await toolRegistry.listExternal()).map((t) => [t.name, t as unknown as { description: string; inputs: unknown }]));
4130
+
4131
+ /** The `sessionId` input's description on a built def, or undefined. */
4132
+ const sessionIdDescriptionOf = (def: { inputs: unknown }): string | undefined =>
4133
+ (def.inputs as { properties?: { body?: { properties?: Record<string, { description?: string }> } } })
4134
+ .properties?.body?.properties?.sessionId?.description;
4135
+
4136
+ it('no tool description and no input description contains the word "ontology"', async () => {
4137
+ await start();
4138
+ for (const tool of await toolRegistry.listExternal()) {
4139
+ expect(tool.description.toLowerCase(), `${tool.name} description`).not.toContain('ontolog');
4140
+ expect(JSON.stringify(tool.inputs).toLowerCase(), `${tool.name} inputs`).not.toContain('ontolog');
4141
+ }
4142
+ });
4143
+
4144
+ it('start_session says what the id is — the conversation\'s, shared with `ask` — and names no ontology', async () => {
4145
+ await start();
4146
+ const description = (await defs()).get('start_session')?.description ?? '';
4147
+ expect(description).toMatch(/conversation/i);
4148
+ expect(description).toContain('`ask`');
4149
+ expect(description.toLowerCase()).not.toContain('ontolog');
4150
+ });
4151
+
4152
+ it('the file tools still accept a sessionId, described as this conversation\'s id', async () => {
4153
+ await start();
4154
+ const all = await defs();
4155
+ for (const name of ['read_file', 'write_file', 'edit_file', 'grep', 'unzip']) {
4156
+ expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(SESSION_ID_DESCRIPTION);
4157
+ }
4158
+ });
4159
+
4160
+ it('a Hexis-only deployment registers no note, so nothing is appended', async () => {
4161
+ await start();
4162
+ const all = await defs();
4163
+ expect(notes.gatedToolNote()).toBe('');
4164
+ expect(all.get('read_file')!.description).not.toContain('Stay within one');
4165
+ });
4166
+
4167
+ it("a registered note lands after every gated tool's own text, ahead of the shared-rules pointer, and on the sessionId input", async () => {
4168
+ await start();
4169
+ notes.registerGatedToolNote(' One folder per conversation.');
4170
+ notes.registerSessionIdNote(' It also pins that folder.');
4171
+ const all = await defs();
4172
+ // The POINTER is last, always: that is the one sentence an agent needs to
4173
+ // find the shared rules, and a description cut short must not lose it. The
4174
+ // deployment's note sits directly before it, after the tool's own text.
4175
+ const pointer = sharedRulesPointer(testKbContext().layout);
4176
+ for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip']) {
4177
+ expect(all.get(name)!.description.endsWith(` One folder per conversation.${pointer}`), name).toBe(true);
4178
+ expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
4179
+ }
4180
+ // `execute_command` is internal-only, so it is checked on that surface.
4181
+ const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
4182
+ expect(internal.get('execute_command')!.description.endsWith(` One folder per conversation.${pointer}`)).toBe(true);
4183
+ });
4184
+
4185
+ it('a tool that is not gated carries no note', async () => {
4186
+ await start();
4187
+ notes.registerGatedToolNote(' One folder per conversation.');
4188
+ const description = (await defs()).get('start_session')?.description ?? '';
4189
+ expect(description).not.toContain('One folder per conversation.');
4190
+ });
4191
+ });
4192
+
4193
+ /**
4194
+ * The bug Juan reported: the dry run of renaming a top-level folder warned he
4195
+ * would lose owner access, and after the move he was still owner. The folder
4196
+ * carried its own `access.md`, which moved with it, and the preview judged
4197
+ * the destination as it stood — where neither the folder nor its rules were
4198
+ * yet.
4199
+ *
4200
+ * These run against the REAL resolver over a real tree, because the thing
4201
+ * under test is what the rules say about a path nothing is at. Every preview
4202
+ * is then held against the operation it predicted: the caller's access at the
4203
+ * destination afterwards must be what the preview answered, or the preview is
4204
+ * wrong again and nobody will find out until the next report.
4205
+ */
4206
+ describe('a move preview judges the destination as it will be', () => {
4207
+ const KB = (p: string) => `${KB_DIR}/${p}`;
4208
+ const MOVER = 'mover@x.io';
4209
+ const rules = (body: string) => `---\n${body}---\n`;
4210
+
4211
+ /**
4212
+ * The real `AccessControlService`, re-read from disk on every question.
4213
+ * In production the file-change notifier invalidates the model after a
4214
+ * write; a test leaning on the five-second cache would be asserting the
4215
+ * cache rather than what the tree now says.
4216
+ */
4217
+ const liveRules = (): IAccessControl => {
4218
+ const fresh = () =>
4219
+ new AccessControlService(
4220
+ {
4221
+ getWorkspacePath: async () => tempDir,
4222
+ ensureRemotesFetched: async () => undefined,
4223
+ } as unknown as WorkspaceService,
4224
+ KB_DIR,
4225
+ new NodeFs(),
4226
+ );
4227
+ return {
4228
+ canRead: (w: string, u: string, r: string) => fresh().canRead(w, u, r),
4229
+ canReadBatch: (w: string, u: string, r: string[]) => fresh().canReadBatch(w, u, r),
4230
+ canWrite: (w: string, u: string, r: string) => fresh().canWrite(w, u, r),
4231
+ canDownload: (w: string, u: string, r: string) => fresh().canDownload(w, u, r),
4232
+ canOwner: (w: string, u: string, r: string) => fresh().canOwner(w, u, r),
4233
+ canWriteBatchAtRef: async () => null,
4234
+ previewAccessAfterRelocation: (
4235
+ w: string, u: string, from: string, to: string, opts?: { sourceRemains?: boolean },
4236
+ ) => fresh().previewAccessAfterRelocation(w, u, from, to, opts),
4237
+ } as unknown as IAccessControl;
4238
+ };
4239
+
4240
+ const call = async (base: string, tool: string, body: Record<string, unknown>) => {
4241
+ const res = await post(`${base}/api/agent/tools/${tool}`, body);
4242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- a tool body is free-form JSON, probed field by field
4243
+ return { status: res.status, body: (await res.json()) as Record<string, any> };
4244
+ };
4245
+
4246
+ /**
4247
+ * `Sales/` grants the caller write and owner in its OWN access.md. `Work/`
4248
+ * grants both too, so `Work/Team` — which grants only write of its own —
4249
+ * holds owner by inheritance and loses it on the way out. `Legal/` grants
4250
+ * read to everyone and nothing else. The root grants read and nothing else,
4251
+ * so a folder that arrives there arrives with only what it brought.
4252
+ */
4253
+ async function seeded(): Promise<string> {
4254
+ const base = await start('write', liveRules(), MOVER);
4255
+ await fs.writeFile(KB('roles.yaml'), 'roles:\n Admin:\n - admin@x.io\n');
4256
+ await fs.writeFile(KB('access.md'), rules('read:\n - everyone\n'));
4257
+ await fs.writeFile(KB('Sales/access.md'), rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`));
4258
+ await fs.writeFile(KB('Sales/deal.md'), 'deal');
4259
+ await fs.writeFile(KB('Work/access.md'), rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`));
4260
+ await fs.writeFile(KB('Work/Notes/note.md'), 'note');
4261
+ await fs.writeFile(KB('Work/Team/access.md'), rules(`write:\n - Mover <${MOVER}>\n`));
4262
+ await fs.writeFile(KB('Work/Team/plan.md'), 'plan');
4263
+ await fs.writeFile(KB('Legal/access.md'), rules('read:\n - everyone\n'));
4264
+ return base;
4265
+ }
4266
+
4267
+ /** The caller's verbs where the thing really is now, through `file_stat`. */
4268
+ const verbsAt = async (base: string, path: string) =>
4269
+ (await call(base, 'file_stat', { path })).body.access;
4270
+
4271
+ it('renaming a folder that names the caller owner previews no loss at all', async () => {
4272
+ const base = await seeded();
4273
+ const args = { src: KB('Sales'), dest: KB('Revenue') };
4274
+
4275
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4276
+ expect(dry.status).toBe(200);
4277
+ expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, allowed: true, moved: false });
4278
+ expect(dry.body.access.after).toEqual(dry.body.access.before);
4279
+ expect(dry.body.access.after.owner).toBe(true);
4280
+ // Nothing moved and nothing was written to answer the question.
4281
+ expect(await fs.exists(args.dest)).toBe(false);
4282
+ expect(await fs.exists(KB('Sales/access.md'))).toBe(true);
4283
+
4284
+ // `accessChanges: false`, so the move itself needs no confirmation.
4285
+ expect((await call(base, 'move_file', args)).body).toMatchObject({ moved: true });
4286
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4287
+ });
4288
+
4289
+ it('moving a folder with no access.md of its own still previews the loss', async () => {
4290
+ const base = await seeded();
4291
+ const args = { src: KB('Work/Notes'), dest: KB('Legal/Notes') };
4292
+
4293
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4294
+ expect(dry.body).toMatchObject({ accessChanges: true, allowed: true });
4295
+ expect(dry.body.access.before.write).toBe(true);
4296
+ expect(dry.body.access.after.write).toBe(false);
4297
+
4298
+ // A changed answer still asks before it moves.
4299
+ expect((await call(base, 'move_file', args)).body).toMatchObject({ confirmationRequired: true, moved: false });
4300
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
4301
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4302
+ });
4303
+
4304
+ it('what the folder inherited is left behind; what its own access.md gives comes along', async () => {
4305
+ const base = await seeded();
4306
+ const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
4307
+
4308
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4309
+ // Write is the folder's own and travels; owner came from `Work/`.
4310
+ expect(dry.body.access.before).toMatchObject({ write: true, owner: true });
4311
+ expect(dry.body.access.after).toMatchObject({ write: true, owner: false });
4312
+
4313
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
4314
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4315
+ });
4316
+
4317
+ it('a nested access.md governs where it lands, not the folder above it', async () => {
4318
+ const base = await seeded();
4319
+ const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
4320
+ const withoutNested = (await call(base, 'move_file', { ...args, dryRun: true })).body.access.after;
4321
+
4322
+ // `Sub/` shuts the caller out and moves too, but it governs `Legal/Team/Sub`.
4323
+ await fs.writeFile(KB('Work/Team/Sub/access.md'), rules(`read:\n - deny Mover <${MOVER}>\n`));
4324
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4325
+ expect(dry.body.access.after).toEqual(withoutNested);
4326
+
4327
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
4328
+ expect(await verbsAt(base, args.dest)).toEqual(withoutNested);
4329
+ // The nested file did land and does govern where it landed: the caller
4330
+ // it shuts out cannot even stat the folder now.
4331
+ expect((await call(base, 'file_stat', { path: KB('Legal/Team/Sub') })).status).toBe(403);
4332
+ });
4333
+
4334
+ it('moving a single file previews as it always did', async () => {
4335
+ const base = await seeded();
4336
+ const args = { src: KB('Sales/deal.md'), dest: KB('Legal/deal.md') };
4337
+
4338
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4339
+ expect(dry.body).toMatchObject({ kind: 'file', descendants: 1 });
4340
+ // Nothing travels with a file but its own bytes, so the answer is the
4341
+ // destination's own rules — what `file_stat` says there already.
4342
+ expect(dry.body.access.after).toEqual(await verbsAt(base, KB('Legal')));
4343
+
4344
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
4345
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4346
+ });
4347
+
4348
+ it('the preview answers the caller\'s own verbs and nothing more', async () => {
4349
+ const base = await seeded();
4350
+ const dry = await call(base, 'move_file', { src: KB('Sales'), dest: KB('Revenue'), dryRun: true });
4351
+
4352
+ expect(Object.keys(dry.body.access).sort()).toEqual(['after', 'before']);
4353
+ for (const side of ['before', 'after'] as const) {
4354
+ expect(Object.keys(dry.body.access[side]).sort()).toEqual(['download', 'owner', 'read', 'write']);
4355
+ }
4356
+ });
4357
+
4358
+ describe('copy_file previews the same way', () => {
4359
+ it('a folder copy counts the copied access.md at the destination', async () => {
4360
+ const base = await seeded();
4361
+ const args = { src: KB('Sales'), dest: KB('Sales-Copy') };
4362
+
4363
+ const dry = await call(base, 'copy_file', { ...args, dryRun: true });
4364
+ expect(dry.status).toBe(200);
4365
+ expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, copied: false, dryRun: true });
4366
+ expect(dry.body.access.after.owner).toBe(true);
4367
+ expect(dry.body.access.after).toEqual(dry.body.access.before);
4368
+
4369
+ // `copy_file` copies ONE FILE, which the preview says rather than
4370
+ // promising a copy that cannot land — and the call itself refuses with
4371
+ // the same sentence, so preflight and execution never disagree.
4372
+ expect(dry.body.allowed).toBe(false);
4373
+ const run = await call(base, 'copy_file', args);
4374
+ expect(run.status).toBe(400);
4375
+ expect(run.body.error).toBe(dry.body.reason);
4376
+ expect(await fs.exists(args.dest)).toBe(false);
4377
+ });
4378
+
4379
+ it('a file copy previews what the caller will have at the destination', async () => {
4380
+ const base = await seeded();
4381
+ const args = { src: KB('Sales/deal.md'), dest: KB('Legal/deal.md') };
4382
+
4383
+ const dry = await call(base, 'copy_file', { ...args, dryRun: true });
4384
+ expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true, copied: false });
4385
+ expect(dry.body.access.before.write).toBe(true);
4386
+ expect(dry.body.access.after.write).toBe(false);
4387
+ expect(dry.body.accessChanges).toBe(true);
4388
+ expect(await fs.exists(args.dest)).toBe(false);
4389
+
4390
+ expect((await call(base, 'copy_file', args)).body).toMatchObject({ copied: true });
4391
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4392
+ // A copy leaves the source exactly as it was.
4393
+ expect(await verbsAt(base, args.src)).toEqual(dry.body.access.before);
4394
+ });
4395
+
4396
+ /**
4397
+ * `copy_file` will copy a lone `access.md`, and the moment it lands it
4398
+ * governs the folder it landed in. The preview counts it: the rules a
4399
+ * copy carries are the point of this ticket whether they travel inside a
4400
+ * folder or on their own.
4401
+ */
4402
+ it('a lone access.md previews the access it will give at the destination', async () => {
4403
+ const base = await seeded();
4404
+ // A folder with no rules of its own, so the destination's answer today
4405
+ // is the root's: read and nothing else. (`Legal/access.md` is taken, and
4406
+ // a copy onto a name that exists is refused long before access is asked.)
4407
+ await fs.writeFile(KB('Open/note.md'), 'note');
4408
+ const args = { src: KB('Sales/access.md'), dest: KB('Open/access.md') };
4409
+
4410
+ const dry = await call(base, 'copy_file', { ...args, dryRun: true });
4411
+ expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true });
4412
+ expect(await verbsAt(base, KB('Open'))).toMatchObject({ write: false, owner: false });
4413
+ expect(dry.body.access.after).toMatchObject({ write: true, owner: true });
4414
+ expect(dry.body.accessChanges).toBe(false);
4415
+
4416
+ expect((await call(base, 'copy_file', args)).body).toMatchObject({ copied: true });
4417
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4418
+ });
4419
+
4420
+ it('a destination that is taken is named, and the dry run changes nothing', async () => {
4421
+ const base = await seeded();
4422
+ const args = { src: KB('Sales/deal.md'), dest: KB('Work/Notes/note.md') };
4423
+
4424
+ const dry = await call(base, 'copy_file', { ...args, dryRun: true });
4425
+ expect(dry.body.allowed).toBe(false);
4426
+ expect(dry.body.reason).toContain('already exists');
4427
+ expect(String(await fs.readFile(KB('Work/Notes/note.md'), { encoding: 'utf-8' }))).toContain('note');
4428
+ });
4429
+ });
4430
+ });