@bevel-software/platform-core-backend 0.22.0 → 0.23.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 (325) 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 +30 -11
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +9 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +112 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  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 +13 -15
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/auth/account-admission.d.ts +58 -7
  34. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  35. package/dist/modules/auth/account-admission.js +44 -1
  36. package/dist/modules/auth/account-admission.js.map +1 -1
  37. package/dist/modules/auth/account.routes.d.ts +1 -1
  38. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  39. package/dist/modules/auth/account.routes.js +61 -5
  40. package/dist/modules/auth/account.routes.js.map +1 -1
  41. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  42. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.middleware.js +18 -7
  44. package/dist/modules/auth/auth.middleware.js.map +1 -1
  45. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  46. package/dist/modules/auth/auth.routes.js +8 -0
  47. package/dist/modules/auth/auth.routes.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts +70 -2
  49. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  50. package/dist/modules/auth/auth.service.js +176 -7
  51. package/dist/modules/auth/auth.service.js.map +1 -1
  52. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  53. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  54. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  55. package/dist/modules/database/core-schema.d.ts +41 -24
  56. package/dist/modules/database/core-schema.d.ts.map +1 -1
  57. package/dist/modules/database/core-schema.js +35 -27
  58. package/dist/modules/database/core-schema.js.map +1 -1
  59. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  60. package/dist/modules/kb-fs/branch-name.js +12 -2
  61. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  62. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  63. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  64. package/dist/modules/kb-fs/repo-path.js +79 -0
  65. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  66. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  67. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  68. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  69. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  70. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  71. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  72. package/dist/modules/kb-sync/sync-auth.js +1 -1
  73. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  74. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  75. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  76. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  77. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  78. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  79. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  80. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  81. package/dist/modules/settings/deployment-settings.service.js +8 -3
  82. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  83. package/dist/modules/settings/setup.routes.d.ts +52 -1
  84. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  85. package/dist/modules/settings/setup.routes.js +207 -19
  86. package/dist/modules/settings/setup.routes.js.map +1 -1
  87. package/dist/modules/skills/skills.contract.d.ts +90 -18
  88. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  89. package/dist/modules/skills/skills.contract.js +4 -1
  90. package/dist/modules/skills/skills.contract.js.map +1 -1
  91. package/dist/modules/skills/skills.service.d.ts +98 -9
  92. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  93. package/dist/modules/skills/skills.service.js +239 -37
  94. package/dist/modules/skills/skills.service.js.map +1 -1
  95. package/dist/modules/skills/skills.tools.d.ts +7 -0
  96. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  97. package/dist/modules/skills/skills.tools.js +71 -7
  98. package/dist/modules/skills/skills.tools.js.map +1 -1
  99. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  100. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  101. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  102. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  103. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  104. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  105. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  106. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  108. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  109. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  110. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  111. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  112. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  113. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  114. package/dist/modules/tool-helpers/validate-token.js +1 -1
  115. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  117. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  118. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  119. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  121. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  122. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  123. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  124. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  125. package/dist/modules/workflow/file-lock.service.js +30 -0
  126. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  127. package/dist/modules/workflow/git/git.service.d.ts +69 -0
  128. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  129. package/dist/modules/workflow/git/git.service.js +153 -2
  130. package/dist/modules/workflow/git/git.service.js.map +1 -1
  131. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  132. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  133. package/dist/modules/workflow/pending-commits.service.js +55 -0
  134. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  135. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  136. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  137. package/dist/modules/workflow/workflow-hooks.js +16 -1
  138. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  139. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  140. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  141. package/dist/modules/workflow/workflow.service.js +111 -4
  142. package/dist/modules/workflow/workflow.service.js.map +1 -1
  143. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  144. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  145. package/dist/modules/workspace/agent-access.gate.js +123 -0
  146. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  147. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  148. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  149. package/dist/modules/workspace/routine-write-policy.js +5 -6
  150. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  151. package/dist/modules/workspace/session-sink.d.ts +5 -5
  152. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  153. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  154. package/dist/modules/workspace/set-aside-clone.js +92 -0
  155. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  156. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  157. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  159. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  160. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.routes.js +92 -3
  162. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  163. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  164. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  165. package/dist/modules/workspace/workspace.service.js +255 -25
  166. package/dist/modules/workspace/workspace.service.js.map +1 -1
  167. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  168. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  169. package/dist/modules/workspace/workspace.tools.js +309 -115
  170. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  171. package/dist/modules/write-access/write-access.d.ts +60 -0
  172. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  173. package/dist/modules/write-access/write-access.js +129 -0
  174. package/dist/modules/write-access/write-access.js.map +1 -0
  175. package/dist/shared/domain-errors.d.ts +25 -0
  176. package/dist/shared/domain-errors.d.ts.map +1 -1
  177. package/dist/shared/domain-errors.js +28 -0
  178. package/dist/shared/domain-errors.js.map +1 -1
  179. package/dist/shared/git.contract.d.ts +20 -0
  180. package/dist/shared/git.contract.d.ts.map +1 -1
  181. package/dist/shared/git.contract.js +26 -0
  182. package/dist/shared/git.contract.js.map +1 -1
  183. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  184. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  185. package/dist/tenancy/static-tenant-source.js +0 -2
  186. package/dist/tenancy/static-tenant-source.js.map +1 -1
  187. package/migrations/0014_change_request_closed_reason.sql +1 -0
  188. package/migrations/0015_account_deactivation.sql +3 -0
  189. package/migrations/meta/0014_snapshot.json +2265 -0
  190. package/migrations/meta/0015_snapshot.json +2271 -0
  191. package/migrations/meta/_journal.json +14 -0
  192. package/package.json +3 -3
  193. package/src/__tests__/kb-layout-config.test.ts +0 -2
  194. package/src/__tests__/retired-settings.test.ts +96 -0
  195. package/src/core/__tests__/gated-boot.test.ts +132 -0
  196. package/src/core/__tests__/lifecycle.test.ts +170 -1
  197. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  198. package/src/core/core-ports.ts +11 -1
  199. package/src/core/create-core-server.ts +32 -14
  200. package/src/core/create-core-services.ts +129 -23
  201. package/src/core/lifecycle.ts +98 -13
  202. package/src/core-config.ts +13 -14
  203. package/src/index.ts +12 -1
  204. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  205. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  206. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  207. package/src/modules/access/access-control.interface.ts +43 -12
  208. package/src/modules/access/access-control.service.ts +234 -36
  209. package/src/modules/access/access.routes.ts +13 -15
  210. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  211. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  212. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  213. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  214. package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
  215. package/src/modules/auth/account-admission.ts +78 -9
  216. package/src/modules/auth/account.routes.ts +63 -7
  217. package/src/modules/auth/auth.middleware.ts +19 -8
  218. package/src/modules/auth/auth.routes.ts +8 -0
  219. package/src/modules/auth/auth.service.ts +179 -6
  220. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  221. package/src/modules/database/core-schema.ts +35 -27
  222. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  223. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  224. package/src/modules/kb-fs/branch-name.ts +14 -1
  225. package/src/modules/kb-fs/repo-path.ts +85 -0
  226. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  227. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  228. package/src/modules/kb-sync/sync-auth.ts +2 -2
  229. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  230. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  231. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  232. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  233. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  234. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  235. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  236. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  237. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  238. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  239. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  240. package/src/modules/settings/deployment-settings.service.ts +8 -3
  241. package/src/modules/settings/setup.routes.ts +263 -19
  242. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  243. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  244. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  245. package/src/modules/skills/skills.contract.ts +91 -18
  246. package/src/modules/skills/skills.service.ts +278 -41
  247. package/src/modules/skills/skills.tools.ts +80 -8
  248. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  249. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  250. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  251. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  252. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  253. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  254. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  255. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  256. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  257. package/src/modules/tool-helpers/validate-token.ts +1 -1
  258. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  259. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  260. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  261. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  262. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  263. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  264. package/src/modules/workflow/file-lock.service.ts +31 -0
  265. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  266. package/src/modules/workflow/git/git.service.ts +168 -0
  267. package/src/modules/workflow/pending-commits.service.ts +59 -1
  268. package/src/modules/workflow/workflow-hooks.ts +64 -26
  269. package/src/modules/workflow/workflow.service.ts +121 -4
  270. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  271. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  272. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  273. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  274. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  275. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  276. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  278. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  279. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  280. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  281. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
  283. package/src/modules/workspace/agent-access.gate.ts +164 -0
  284. package/src/modules/workspace/routine-write-policy.ts +5 -6
  285. package/src/modules/workspace/session-sink.ts +5 -5
  286. package/src/modules/workspace/set-aside-clone.ts +96 -0
  287. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  288. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  289. package/src/modules/workspace/workspace.routes.ts +95 -3
  290. package/src/modules/workspace/workspace.service.ts +256 -24
  291. package/src/modules/workspace/workspace.tools.ts +338 -119
  292. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  293. package/src/modules/write-access/write-access.ts +153 -0
  294. package/src/shared/domain-errors.ts +31 -0
  295. package/src/shared/git.contract.ts +27 -0
  296. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  297. package/src/tenancy/static-tenant-source.ts +0 -3
  298. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  299. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  300. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  301. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  302. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  303. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  304. package/dist/modules/workflow/session-ontology.service.js +0 -147
  305. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  306. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  307. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  308. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  309. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  310. package/dist/shared/kb-layout.d.ts +0 -39
  311. package/dist/shared/kb-layout.d.ts.map +0 -1
  312. package/dist/shared/kb-layout.js +0 -103
  313. package/dist/shared/kb-layout.js.map +0 -1
  314. package/dist/shared/kb-layout.test.d.ts +0 -2
  315. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  316. package/dist/shared/kb-layout.test.js +0 -75
  317. package/dist/shared/kb-layout.test.js.map +0 -1
  318. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  319. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  320. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  321. package/src/modules/workflow/session-ontology.service.ts +0 -183
  322. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  323. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  324. package/src/shared/kb-layout.test.ts +0 -98
  325. package/src/shared/kb-layout.ts +0 -102
@@ -7,13 +7,11 @@ import type { IToolRegistry, JsonSchema } from '../tool-registry/tool.contract.j
7
7
  import { ToolError, type ToolContext, type ToolHandler } from '../tool-helpers/tool.contract.js';
8
8
  import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
9
9
  import {
10
- recordOntologyRead,
11
- assertOntologyWriteAllowed,
12
- assertShellAllowedWithinOntology,
13
- ONTOLOGY_BOUNDARY_NOTE,
10
+ notifyAgentRead,
11
+ assertAgentWriteAllowed,
14
12
  SESSION_ID_INPUT,
15
- type SessionOntologyGate,
16
- } from './session-ontology.gate.js';
13
+ type AgentAccessGate,
14
+ } from './agent-access.gate.js';
17
15
  import type { IRoutineWritePolicy } from './routine-write-policy.js';
18
16
  import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
19
17
  import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
@@ -436,6 +434,27 @@ const WRITE_MODE_NOTE =
436
434
  '(creating it if there is nothing), `update` replaces an existing file and refuses a path that does not exist (`missing`). ' +
437
435
  'A refused path is left exactly as it was.';
438
436
 
437
+ /**
438
+ * What an agent needs to know about escape sequences in the content it sends,
439
+ * on the three tools that take content as a JSON string.
440
+ *
441
+ * The three write routes — the MCP endpoint, the `/api/agent/tools/<name>`
442
+ * route and `call_tool_chain` — were measured end to end against raw requests
443
+ * and a byte-level read of the stored file (see
444
+ * `__tests__/escape-sequences.routes.test.ts`): each stores content exactly as
445
+ * the JSON string value decodes ONCE. So when an escape arrives already
446
+ * decoded, the decoding happened in the client that built the request, and no
447
+ * tool here can tell that content from content that was meant to be decoded.
448
+ * Hence a warning rather than a fix, and the pointer to the one route whose
449
+ * payload is bytes rather than a JSON string.
450
+ */
451
+ const ESCAPE_SEQUENCE_NOTE =
452
+ ' Escape sequences: some clients decode them in arguments before sending, so content meant to CONTAIN an escape rather ' +
453
+ 'than what it stands for (the six characters backslash, `u`, `0`, `0`, `4`, `1`, say, rather than the letter `A`) can ' +
454
+ 'reach this tool already decoded — what arrives is stored byte for byte, so when that distinction matters, verify what ' +
455
+ 'landed (`read_file`, or a hash) and send such content through the upload route (`request_upload_token` + `apply_upload` ' +
456
+ 'where offered, otherwise Upload in the app), which lands it unchanged.';
457
+
439
458
  /** The refusal `create` gives on a path that already holds something. */
440
459
  function pathExists(path: string): ToolError {
441
460
  return new ToolError(
@@ -591,7 +610,7 @@ async function grepWalk(
591
610
  max: number,
592
611
  depth: number,
593
612
  gate: ReadGate,
594
- recordOntologyRead: (path: string) => Promise<void>,
613
+ notifyRead: (path: string) => Promise<void>,
595
614
  docs: DocGrepState,
596
615
  ): Promise<void> {
597
616
  if (out.length >= max || depth > 12) return;
@@ -610,12 +629,12 @@ async function grepWalk(
610
629
  if (e.type !== 'directory' && isFolderPlaceholder(e.name)) continue;
611
630
  const p = dir ? `${dir}/${e.name}` : e.name;
612
631
  if (e.type === 'directory') {
613
- await grepWalk(fs, p, re, out, max, depth + 1, gate, recordOntologyRead, docs);
632
+ await grepWalk(fs, p, re, out, max, depth + 1, gate, notifyRead, docs);
614
633
  } else {
615
- // Opening a file under a named ontology is a read of that ontology — even
616
- // for a root-level grep that resolves to a neutral root. Record it so a
617
- // cross-ontology grep poisons later writes (closes the read-leak).
618
- await recordOntologyRead(p);
634
+ // Opening a file is a read of it, even when the walk started at a root
635
+ // the read hook was already told about — so every file the walk opens
636
+ // reaches the hook by name (closes the read-leak).
637
+ await notifyRead(p);
619
638
  // A file the walk cannot read is silently skipped: one unreadable entry
620
639
  // must not fail a search over the whole tree.
621
640
  try {
@@ -663,6 +682,20 @@ const BATCH_SAVE_WARNINGS_OUTPUT: JsonSchema = {
663
682
  items: { type: 'object' },
664
683
  };
665
684
 
685
+ /**
686
+ * The `sessionId` property inside a BUILT tool def's input schema, or
687
+ * `undefined` for a tool that declares none. `toolDef` wraps the flat inputs
688
+ * under `body` and copies the schema it is given, so a note registered after
689
+ * the tools were built has to be written here rather than onto the shared
690
+ * `SESSION_ID_INPUT` constant.
691
+ */
692
+ function sessionIdInputOf(def: { inputs?: unknown }): { description?: string } | undefined {
693
+ const inputs = def.inputs as
694
+ | { properties?: { body?: { properties?: Record<string, { description?: string }> } } }
695
+ | undefined;
696
+ return inputs?.properties?.body?.properties?.sessionId;
697
+ }
698
+
666
699
  /**
667
700
  * Workspace domain tools: the file primitives (replacing Mastra's auto-injected
668
701
  * Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
@@ -681,7 +714,7 @@ export function registerWorkspaceTools(
681
714
  docExtract: DocExtractService,
682
715
  accessControl: IAccessControl,
683
716
  kb: KbContext,
684
- sessionOntologyGate: SessionOntologyGate,
717
+ agentAccessGate: AgentAccessGate,
685
718
  writePolicy: IRoutineWritePolicy,
686
719
  sessionSink: ISessionSink,
687
720
  /**
@@ -780,7 +813,7 @@ export function registerWorkspaceTools(
780
813
  * Every spelling first, then the resolved form against the branch's
781
814
  * workspace, so a link into the folder is refused the same way. The resolved
782
815
  * check only runs on a branch that is already cloned: bootstrapping a clone
783
- * here would happen before the handler's access and ontology gates. A branch
816
+ * here would happen before the handler's access and agent-access gates. A branch
784
817
  * not cloned yet (or that does not resolve) is left to the handler; the
785
818
  * filesystem refuses again underneath regardless.
786
819
  */
@@ -802,7 +835,7 @@ export function registerWorkspaceTools(
802
835
  * The branch's workspace root, to judge a spelling against what is on disk
803
836
  * — or null when there is nothing to judge it against yet. Only a branch
804
837
  * ALREADY cloned is used: bootstrapping one here would clone before the
805
- * handler's access and ontology gates have had their say.
838
+ * handler's access and agent-access gates have had their say.
806
839
  */
807
840
  const gitCheckRootFor = async (args: Record<string, unknown>, ctx: ToolContext): Promise<string | null> => {
808
841
  if (typeof args.branch !== 'string' || args.branch === '') return null;
@@ -865,6 +898,128 @@ export function registerWorkspaceTools(
865
898
  return { read, write, download, owner };
866
899
  };
867
900
 
901
+ /** Whether any one of the caller's four verdicts differs between the two sides of a preview. */
902
+ const verbsDiffer = (before: AccessVerbs, after: AccessVerbs): boolean =>
903
+ (Object.keys(before) as (keyof AccessVerbs)[]).some((v) => before[v] !== after[v]);
904
+
905
+ /**
906
+ * Why `copy_file` will not take a folder. One sentence, said by the dry
907
+ * run and by the call itself, so the preflight and the execution never
908
+ * disagree — the rule this whole section is built on.
909
+ */
910
+ const folderCopyRefusal = (src: string): string =>
911
+ `"${src}" is a folder; copy_file copies one file. Copy its files one by one, or move the folder with move_file.`;
912
+
913
+ /**
914
+ * The caller's verdicts at `dest` as they will be once `src` has been
915
+ * moved (or, with `sourceRemains`, copied) there — the `after` half of a
916
+ * move's or copy's preview.
917
+ *
918
+ * `accessAt(dest)` is the wrong answer to that question for a folder: the
919
+ * destination on disk has neither the folder nor the `access.md` files it
920
+ * carries, so it describes the destination's PARENT. A rename of a folder
921
+ * that names the caller owner in its own `access.md` therefore warned
922
+ * about losing owner access the move was about to hand straight back, and
923
+ * a warning that is wrong is a warning people learn to click through.
924
+ *
925
+ * Preview only, like everything else in this section: it answers what the
926
+ * caller WILL have, never whether they may do it. The write verdicts that
927
+ * gate the move are `writeBlocked` and the lock gate, both of which read
928
+ * the tree as it is.
929
+ */
930
+ const accessAfter = async (
931
+ branch: string,
932
+ ctx: ToolContext,
933
+ src: string,
934
+ dest: string,
935
+ opts?: { sourceRemains?: boolean },
936
+ ): Promise<AccessVerbs> => {
937
+ const from = toKbRelative(src, kbDirName);
938
+ const to = toKbRelative(dest, kbDirName);
939
+ // Outside the repository there are no rules to carry, and none to land
940
+ // among — the same answer `accessAt` gives for such a path.
941
+ if (from === null || to === null) return accessAt(branch, ctx, dest);
942
+ return accessControl.previewAccessAfterRelocation(
943
+ workspaceIdForBranch(branch),
944
+ ctx.user.email,
945
+ from,
946
+ to,
947
+ opts,
948
+ );
949
+ };
950
+
951
+ /**
952
+ * What `copy_file`'s dry run answers: the same impact shape `move_file`
953
+ * previews, over a copy's own rules.
954
+ *
955
+ * A copy LEAVES the source where it is, so the rules it carries are
956
+ * duplicated rather than relocated (`sourceRemains`) — otherwise the two
957
+ * previews ask the same question. The order of the refusals is `copy_file`'s
958
+ * own and is load-bearing: the write verdict on the destination outranks
959
+ * "that name is taken", because a caller who may not write a folder must
960
+ * not learn what is in it from a refusal.
961
+ *
962
+ * A folder source is reported as the refusal it is. `copy_file` copies one
963
+ * file; the preview says so rather than promising a copy that would fail,
964
+ * and still answers `access.after` for the folder it was asked about.
965
+ *
966
+ * NOTHING is probed on disk until the write verdict on the destination has
967
+ * been taken — not the destination, and not the source either, which is the
968
+ * order the call itself keeps at length: a caller who may not write there
969
+ * gets the same refusal whether the source is a file, a folder, or missing
970
+ * altogether. Probing the source first put a 404 in front of that 403 and
971
+ * handed a denied caller the source's kind and its file count. So a refused
972
+ * preview answers `allowed: false` with the sentence and no `kind` or
973
+ * `descendants`: those are the half of the impact the caller has to have
974
+ * earned. The two `access` sides are the caller's own four verbs and tell
975
+ * them nothing they could not ask `file_stat` for.
976
+ */
977
+ const copyImpact = async (branch: string, ctx: ToolContext, src: string, dest: string) => {
978
+ const [before, after, blocked] = await Promise.all([
979
+ accessAt(branch, ctx, src),
980
+ accessAfter(branch, ctx, src, dest, { sourceRemains: true }),
981
+ writeBlocked(branch, ctx, [dest]),
982
+ ]);
983
+ const access = { before, after };
984
+ const accessChanges = verbsDiffer(before, after);
985
+ if (blocked.length > 0) {
986
+ return {
987
+ src,
988
+ dest,
989
+ access,
990
+ accessChanges,
991
+ allowed: false,
992
+ reason: `You may not write "${dest}", so the copy cannot run.`,
993
+ dryRun: true,
994
+ copied: false,
995
+ };
996
+ }
997
+ const fs = await ctx.getFilesystem(branch);
998
+ const kind = await kindOf(fs, src);
999
+ if (kind === null) throw notFound(src, 'Nothing to copy');
1000
+ const srcFiles = kind === 'folder' ? (await filesUnder(fs, src)).files : [src];
1001
+ const occupiedBy = await existingAt(await workspaceRoot(branch, ctx), dest);
1002
+ const reason = occupiedBy !== null
1003
+ ? entryExistsMessage(occupiedBy, dest)
1004
+ : kind === 'folder'
1005
+ ? folderCopyRefusal(src)
1006
+ : undefined;
1007
+ return {
1008
+ src,
1009
+ dest,
1010
+ kind,
1011
+ // The placeholder travels with its folder, but it is never content —
1012
+ // counted as `move_file` counts it.
1013
+ descendants: srcFiles.filter((f) => !isFolderPlaceholder(f)).length,
1014
+ access,
1015
+ accessChanges,
1016
+ allowed: reason === undefined,
1017
+ ...(reason !== undefined ? { reason } : {}),
1018
+ dryRun: true,
1019
+ copied: false,
1020
+ };
1021
+ };
1022
+
868
1023
  /**
869
1024
  * The paths among `paths` the caller may NOT write, judged exactly as the
870
1025
  * lock gate judges them (`WorkflowService.acquireLock`): on a protected
@@ -1172,6 +1327,14 @@ export function registerWorkspaceTools(
1172
1327
  proposable?: boolean;
1173
1328
  /** False for a tool that is not a file tool (the shell), which the content rule does not describe. */
1174
1329
  fileTool?: boolean;
1330
+ /**
1331
+ * This tool runs through the agent-access gate, so a call to it reaches
1332
+ * the deployment's read or write hook. Such a tool carries the note the
1333
+ * deployment registered ({@link ToolDescriptionNotes}) at the end of its
1334
+ * description — core registers none, so on a core-only deployment the
1335
+ * flag adds nothing to what the agent reads.
1336
+ */
1337
+ gated?: boolean;
1175
1338
  /**
1176
1339
  * This tool resolves `branch` ITSELF and must not be pre-checked here.
1177
1340
  * Only `execute_command` sets it: for an internal session that leaves the
@@ -1198,7 +1361,8 @@ export function registerWorkspaceTools(
1198
1361
  (typeof spec.description === 'function' ? spec.description() : spec.description) +
1199
1362
  (spec.proposable ? PROPOSAL_ROUTE_NOTE : '') +
1200
1363
  (spec.fileTool === false ? '' : CONTENT_RULE) +
1201
- kbConventionsNote(kb.layout);
1364
+ kbConventionsNote(kb.layout) +
1365
+ (spec.gated ? agentAccessGate.notes.gatedToolNote() : '');
1202
1366
  const def = toolDef({
1203
1367
  name: spec.name,
1204
1368
  description: describe(),
@@ -1209,16 +1373,32 @@ export function registerWorkspaceTools(
1209
1373
  });
1210
1374
  registry.registerInternalTool(def);
1211
1375
  if (!spec.internalOnly) registry.registerExternalTool(def);
1212
- // The catalog FOLLOWS the layout. The conventions reminder above names the
1213
- // guide, and several descriptions name it again as a platform file, so the
1214
- // save that completes first-run setup — which applies the names the admin
1215
- // just chose, in that same request, without a restart — must be able to
1216
- // move the text with them. Rewritten in place: the registry holds this
1217
- // object, both surfaces hold the same one, and re-registering would be a
1218
- // duplicate name.
1219
- kb.onLayoutApplied(() => {
1376
+ /**
1377
+ * What the agent reads about this tool, rebuilt from whatever is in effect
1378
+ * NOW: the layout's names and the notes the deployment registered.
1379
+ *
1380
+ * The catalog FOLLOWS the layout. The conventions reminder above names the
1381
+ * guide, and several descriptions name it again as a platform file, so the
1382
+ * save that completes first-run setup — which applies the names the admin
1383
+ * just chose, in that same request, without a restart — must be able to
1384
+ * move the text with them. Rewritten in place: the registry holds this
1385
+ * object, both surfaces hold the same one, and re-registering would be a
1386
+ * duplicate name. The `sessionId` input is rewritten on the DEF rather
1387
+ * than on `SESSION_ID_INPUT`, because `toolDef` copies the schema it is
1388
+ * given.
1389
+ */
1390
+ const redescribe = (): void => {
1220
1391
  def.description = describe();
1221
- });
1392
+ const sessionId = sessionIdInputOf(def);
1393
+ if (sessionId) sessionId.description = agentAccessGate.notes.sessionIdDescription();
1394
+ };
1395
+ // Once for a note registered BEFORE the tools were mounted (the `sessionId`
1396
+ // input is copied by `toolDef`, so it carries the bare default until this
1397
+ // runs), and then on every later change: a note may be registered AFTER
1398
+ // the mount, from the tool-surface hook an overlay registers on.
1399
+ redescribe();
1400
+ kb.onLayoutApplied(redescribe);
1401
+ agentAccessGate.notes.onChange(redescribe);
1222
1402
  // Internal-only tools (e.g. `execute_command`) keep their route mounted —
1223
1403
  // our agent calls it over the same loopback — but gate it to internal-source
1224
1404
  // callers so an external connection key can't invoke it by name.
@@ -1288,22 +1468,20 @@ export function registerWorkspaceTools(
1288
1468
  };
1289
1469
 
1290
1470
  // ── session bootstrap (external agents) ─────────────────────────────────
1291
- // Every read/write tool below scopes the ontology-session boundary off a
1292
- // `sessionId`. The in-process agent carries its thread id, but an external
1293
- // agent has no ambient run id and so cannot satisfy the gate until it has
1294
- // one. This mints that id up front (called ONCE); the MCP proxy then threads
1295
- // it onto every later gated call via its sessionId-output continuity
1471
+ // Every read/write tool below takes a `sessionId`: the conversation the
1472
+ // call belongs to, which is what a deployment's hooks scope their rule to.
1473
+ // The in-process agent carries its thread id, but an external agent has no
1474
+ // ambient run id, so this mints one up front (called ONCE); the MCP proxy
1475
+ // then threads it onto every later call via its sessionId-output continuity
1296
1476
  // convention. EXTERNAL-ONLY (not registered internal): the in-process agent
1297
1477
  // already supplies its session id and ignores any body value.
1298
1478
  //
1299
1479
  // WHAT the minted id is backed by is the `ISessionSink` port's business
1300
1480
  // (session-sink.ts). In the enterprise app it is a REAL chat-thread id, so
1301
- // the SAME id works end to end: KB reads scope the ontology boundary under
1302
- // it, AND `ask` accepts it (its sessionId IS a chat thread, resolved via
1303
- // getThread) — that unification is what stops a caller reading from one
1304
- // ontology and then having `ask` write into another. In a core-only
1305
- // deployment (no chat/ask) the default sink mints a bare id, which is all
1306
- // the ontology gate needs.
1481
+ // the SAME id works end to end: the file tools take it AND `ask` accepts it
1482
+ // (its sessionId IS a chat thread, resolved via getThread), so a run's reads
1483
+ // and its questions are one conversation rather than two. In a core-only
1484
+ // deployment (no chat/ask) the default sink mints a bare id.
1307
1485
  //
1308
1486
  // The description tells the caller that retrying is safe, and that is a
1309
1487
  // property of the sink rather than a promise this route makes on its own:
@@ -1316,7 +1494,7 @@ export function registerWorkspaceTools(
1316
1494
  const startSessionDef = toolDef({
1317
1495
  name: 'start_session',
1318
1496
  description:
1319
- 'Mint the KnowledgeBase session id this run needs to read or write the knowledge ontologies. Call this ONCE, before any other KnowledgeBase tool, and only once per run — every gated tool needs the `sessionId` it returns to enforce the one-ontology-per-conversation boundary, and minting a new id mid-run resets that boundary. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: reads and ask then share one ontology boundary. Pass the returned id explicitly as `sessionId` on every subsequent KnowledgeBase tool call (direct MCP calls and inside `call_tool_chain` alike). RETRYING IS SAFE: a call that fails created nothing, so retry it — there is no half-made session to clean up. If a retry lands after a success you simply hold two independent ids, which is harmless: keep passing the one id you have already used for the rest of the run and ignore the other. Returns `{ sessionId }`.',
1497
+ 'Mint the id of this conversation, which the KnowledgeBase tools take as `sessionId`. Call this ONCE, at the start of your work and only once per run — minting a new id mid-run starts a second conversation as far as the server is concerned. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: your reads and your questions are then one conversation. Pass the returned id explicitly as `sessionId` on every subsequent KnowledgeBase tool call (direct MCP calls and inside `call_tool_chain` alike). RETRYING IS SAFE: a call that fails created nothing, so retry it — there is no half-made session to clean up. If a retry lands after a success you simply hold two independent ids, which is harmless: keep passing the one id you have already used for the rest of the run and ignore the other. Returns `{ sessionId }`.',
1320
1498
  path: '/api/agent/tools/start_session',
1321
1499
  inputs: { type: 'object', properties: {}, additionalProperties: false },
1322
1500
  outputs: {
@@ -1328,12 +1506,12 @@ export function registerWorkspaceTools(
1328
1506
  });
1329
1507
  registry.registerExternalTool(startSessionDef);
1330
1508
  // Mint the session id via the sink and return it (see comment above: one id
1331
- // spans start_session -> reads -> ask, closing the ontology-pollution gap).
1509
+ // spans start_session -> reads -> ask).
1332
1510
  router.post(
1333
1511
  '/agent/tools/start_session',
1334
1512
  toolAuth,
1335
1513
  // External-only: an internal token already carries its run's sessionId, so
1336
- // minting a new thread mid-run would reset the ontology boundary. Note
1514
+ // minting a new thread mid-run would split one run in two. Note
1337
1515
  // "external" includes the MCP proxy's `externalProxy` loopback tokens
1338
1516
  // (OAuth/JWT MCP sessions) — the verifier resolves those to
1339
1517
  // `source: 'external'`, and one such session may legitimately mint several
@@ -1348,9 +1526,9 @@ export function registerWorkspaceTools(
1348
1526
  // ── reads ──────────────────────────────────────────────────────────────
1349
1527
  mount({
1350
1528
  name: 'read_file',
1529
+ gated: true,
1351
1530
  description:
1352
- 'Read a workspace file as text. Returns `{ path, content }`. Images (.png/.jpg/.jpeg/.gif/.webp) return the IMAGE ITSELF as native MCP image content (plus a one-line text note naming the file), so you can look at the picture — up to 3.5 MB of raw image data; a larger image gets an honest refusal asking for a locally downscaled copy or a smaller export (`.svg` is text and reads as text). Images come back only on a DIRECT call: inside `call_tool_chain` an image read yields an `{ image_omitted, note }` stub instead. Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods) and PDFs return their EXTRACTED text under an honest `[extracted text of …]` header, with `[slide N]`/`[sheet: Name]`/`[page N]` markers — the extraction is READ-ONLY (layout/images omitted; such files cannot be edited as text, only replaced by uploading a new version). Email files (.eml/.msg) return their EXTRACTED text the same way: a `[from]`/`[to]`/`[subject]`/`[date]` header block, the body (plain-text part preferred; an HTML-only body is stripped to text), and an `[attachments]` name list — attachments are listed, never extracted. Other binary files return a one-line description instead of raw bytes. Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. A spill ref is workspace-independent: `branch` is ignored for it.' +
1353
- ONTOLOGY_BOUNDARY_NOTE,
1531
+ 'Read a workspace file as text. Returns `{ path, content }`. Images (.png/.jpg/.jpeg/.gif/.webp) return the IMAGE ITSELF as native MCP image content (plus a one-line text note naming the file), so you can look at the picture — up to 3.5 MB of raw image data; a larger image gets an honest refusal asking for a locally downscaled copy or a smaller export (`.svg` is text and reads as text). Images come back only on a DIRECT call: inside `call_tool_chain` an image read yields an `{ image_omitted, note }` stub instead. Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods) and PDFs return their EXTRACTED text under an honest `[extracted text of …]` header, with `[slide N]`/`[sheet: Name]`/`[page N]` markers — the extraction is READ-ONLY (layout/images omitted; such files cannot be edited as text, only replaced by uploading a new version). Email files (.eml/.msg) return their EXTRACTED text the same way: a `[from]`/`[to]`/`[subject]`/`[date]` header block, the body (plain-text part preferred; an HTML-only body is stripped to text), and an `[attachments]` name list — attachments are listed, never extracted. Other binary files return a one-line description instead of raw bytes. Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. A spill ref is workspace-independent: `branch` is ignored for it.',
1354
1532
  inputs: {
1355
1533
  type: 'object',
1356
1534
  properties: {
@@ -1376,12 +1554,12 @@ export function registerWorkspaceTools(
1376
1554
  if (spillStore.isSpillRef(p)) {
1377
1555
  return { path: p, content: await spillStore.read(p, offset, limit) };
1378
1556
  }
1379
- await recordOntologyRead(sessionOntologyGate, ctx, p);
1557
+ await notifyAgentRead(agentAccessGate, ctx, a.branch as string, p);
1380
1558
  await assertCanRead(readGateFor(a.branch as string, ctx), p);
1381
1559
  const fs = await ctx.getFilesystem(a.branch as string);
1382
1560
  // Reading (extraction, image and binary handling included) happens AFTER
1383
- // the access gate and the ontology-read recording above — a document
1384
- // read is still a KB read. ONE registry dispatch picks the reader by
1561
+ // the access gate and the read hook above — a document read is still a
1562
+ // KB read. ONE registry dispatch picks the reader by
1385
1563
  // extension; everything below just maps its ReadResult onto the tool's
1386
1564
  // result shape.
1387
1565
  const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
@@ -1414,9 +1592,9 @@ export function registerWorkspaceTools(
1414
1592
 
1415
1593
  mount({
1416
1594
  name: 'list_files',
1595
+ gated: true,
1417
1596
  description:
1418
- `List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it.` +
1419
- ONTOLOGY_BOUNDARY_NOTE,
1597
+ `List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it.`,
1420
1598
  inputs: {
1421
1599
  type: 'object',
1422
1600
  properties: {
@@ -1446,7 +1624,7 @@ export function registerWorkspaceTools(
1446
1624
  write: false,
1447
1625
  handler: async (a, ctx: ToolContext) => {
1448
1626
  const dir = (a.path as string) || '';
1449
- await recordOntologyRead(sessionOntologyGate, ctx, dir);
1627
+ await notifyAgentRead(agentAccessGate, ctx, a.branch as string, dir);
1450
1628
  const fs = await ctx.getFilesystem(a.branch as string);
1451
1629
  const entries = withoutPlaceholder((await fs.readdir(dir || '.')) as DirEntry[]);
1452
1630
  const filtered = await filterReadableEntries(readGateFor(a.branch as string, ctx), dir, entries);
@@ -1456,14 +1634,14 @@ export function registerWorkspaceTools(
1456
1634
 
1457
1635
  mount({
1458
1636
  name: 'file_stat',
1637
+ gated: true,
1459
1638
  description: () =>
1460
1639
  'Get a file/directory\'s metadata (name, type, size, …) without returning content. A file also reports `contentMode`: `text` (read, write and edit it as text), `document` (read returns an extraction; replace it by upload) or `binary` (bytes: copy, move, delete, or replace by upload), plus `kind` (`text` | `document` | `image` | `binary`), `mime`, `mimeSource` and `textEditable` — decided by the same file readers read_file, grep and the write tools use, so an extensionless text file is `text/plain`.' +
1461
1640
  ' Every entry also reports what you may DO with it. ' +
1462
1641
  `\`managed\` is true for a platform item — a platform file (${platformFileList(kb.layout)}) or a platform folder (the repository root or a reserved root folder such as \`KnowledgeBase/\`); managed items are never movable or deletable through these tools. ` +
1463
1642
  '`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to learn why, and who else holds each verb. `movable` and `deletable` say whether `move_file` / `delete_file` / `delete_folder` would be allowed for you, judged like their dry runs: not managed, no symbolic link, and on a protected branch you hold write on the item AND on every file under a folder (on a draft branch writes are not gated). `movable` judges the source side only; the destination is judged by a `move_file` dry run. ' +
1464
1643
  'For a folder, `descendants` is the number of files under it at any depth; counting stops at 10000 and `descendantsTruncated` says so, and past that point `movable` and `deletable` are false because a folder that large was not judged in full — run the `move_file` or `delete_folder` dry run for the real verdict. ' +
1465
- 'Call this before a move or delete to see what it would touch.' +
1466
- ONTOLOGY_BOUNDARY_NOTE,
1644
+ 'Call this before a move or delete to see what it would touch.',
1467
1645
  inputs: {
1468
1646
  type: 'object',
1469
1647
  properties: {
@@ -1539,7 +1717,7 @@ export function registerWorkspaceTools(
1539
1717
  handler: async (a, ctx: ToolContext) => {
1540
1718
  const p = a.path as string;
1541
1719
  const branch = a.branch as string;
1542
- await recordOntologyRead(sessionOntologyGate, ctx, p);
1720
+ await notifyAgentRead(agentAccessGate, ctx, branch, p);
1543
1721
  await assertCanRead(readGateFor(branch, ctx), p);
1544
1722
  // Nothing there is a 404, and the placeholder — never content — gets
1545
1723
  // exactly that answer: the one every file tool gives (see not-found.ts).
@@ -1640,9 +1818,9 @@ export function registerWorkspaceTools(
1640
1818
 
1641
1819
  mount({
1642
1820
  name: 'grep',
1821
+ gated: true,
1643
1822
  description:
1644
- 'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. `path` may name a DIRECTORY (searches the subtree) or a single FILE (searches just that file); a path with nothing at it is an error, never an empty result. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).' +
1645
- ONTOLOGY_BOUNDARY_NOTE,
1823
+ 'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. `path` may name a DIRECTORY (searches the subtree) or a single FILE (searches just that file); a path with nothing at it is an error, never an empty result. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).',
1646
1824
  inputs: {
1647
1825
  type: 'object',
1648
1826
  properties: {
@@ -1692,11 +1870,10 @@ export function registerWorkspaceTools(
1692
1870
  // (an empty path is the handler's to explain), and here it would
1693
1871
  // otherwise name the workspace directory by another spelling.
1694
1872
  const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
1695
- // The search root itself is checked here (fail-closed for an agent grep on
1696
- // a named subtree with no sessionId); each file the walk actually opens is
1697
- // recorded per-file below, so a root-level grep that reaches into multiple
1698
- // ontologies still records each one (and can poison later writes).
1699
- await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
1873
+ // The search root itself goes to the read hook here; each file the walk
1874
+ // actually opens goes to it per-file below, so a hook sees every path a
1875
+ // grep reached rather than only the root it started from.
1876
+ await notifyAgentRead(agentAccessGate, ctx, a.branch as string, searchRoot);
1700
1877
  const fs = await ctx.getFilesystem(a.branch as string);
1701
1878
  const gate = readGateFor(a.branch as string, ctx);
1702
1879
  const out: { path: string; line: number; text: string }[] = [];
@@ -1723,7 +1900,7 @@ export function registerWorkspaceTools(
1723
1900
  max,
1724
1901
  0,
1725
1902
  gate,
1726
- (p) => recordOntologyRead(sessionOntologyGate, ctx, p),
1903
+ (p) => notifyAgentRead(agentAccessGate, ctx, a.branch as string, p),
1727
1904
  docs,
1728
1905
  );
1729
1906
  } else {
@@ -1768,12 +1945,13 @@ export function registerWorkspaceTools(
1768
1945
  // ── writes (through the lock/commit pipeline) ───────────────────────────
1769
1946
  mount({
1770
1947
  name: 'write_file',
1948
+ gated: true,
1771
1949
  description:
1772
1950
  'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
1773
1951
  '`created`, `replaced` or `updated`.' +
1774
1952
  WRITE_MODE_NOTE +
1775
1953
  IMAGE_CONVENTION_NOTE +
1776
- ONTOLOGY_BOUNDARY_NOTE,
1954
+ ESCAPE_SEQUENCE_NOTE,
1777
1955
  inputs: {
1778
1956
  type: 'object',
1779
1957
  properties: {
@@ -1809,7 +1987,7 @@ export function registerWorkspaceTools(
1809
1987
  // (today only `watchlist_check`, to `.html`). Unrestricted sessions pass straight
1810
1988
  // through (see `assertPathWritable`), so it does not limit other agents.
1811
1989
  writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
1812
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
1990
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
1813
1991
  const mode = modeOf(a);
1814
1992
  const fs = await ctx.getFilesystem(a.branch as string);
1815
1993
  await assertNotBinaryOverwrite(readers,a.path as string, fs);
@@ -1851,18 +2029,19 @@ export function registerWorkspaceTools(
1851
2029
 
1852
2030
  mount({
1853
2031
  name: 'write_files',
2032
+ gated: true,
1854
2033
  description:
1855
2034
  'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
1856
2035
  'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`, and the files it ' +
1857
2036
  'writes are committed + pushed together as you. Prefer this over many write_file ' +
1858
- 'calls. All files must be in the SAME ontology (the boundary below applies to the batch). Text files only. ' +
2037
+ 'calls. Text files only. ' +
1859
2038
  'Returns `{ count, files }`: one entry per REQUESTED path, in the order you gave them, each `{ path, outcome }` — ' +
1860
2039
  '`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
1861
2040
  'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
1862
2041
  'does not stop the others; read `files` to see what landed.' +
1863
2042
  WRITE_MODE_NOTE +
1864
2043
  IMAGE_CONVENTION_NOTE +
1865
- ONTOLOGY_BOUNDARY_NOTE,
2044
+ ESCAPE_SEQUENCE_NOTE,
1866
2045
  inputs: {
1867
2046
  type: 'object',
1868
2047
  properties: {
@@ -1915,13 +2094,13 @@ export function registerWorkspaceTools(
1915
2094
  const files = (a.files as Array<{ path: string; content: string }>) ?? [];
1916
2095
  if (files.length === 0) return { count: 0, files: [] };
1917
2096
  const mode = modeOf(a);
1918
- // The POLICY gates still judge the whole batch: a restricted run or a
1919
- // cross-ontology batch is a call that should not have been made at all,
1920
- // not a per-path outcome, and the ontology gate must see every path
1921
- // before anything lands. What a single FILE is (not text) or what its
1922
- // path already holds (the mode) is decided per path, below.
2097
+ // The POLICY gate still judges the whole batch: a restricted run is a
2098
+ // call that should not have been made at all, not a per-path outcome.
2099
+ // The write hook is asked PER PATH, below, so a path it refuses is that
2100
+ // path's outcome and the rest of the batch still lands. What a single
2101
+ // FILE is (not text) or what its path already holds (the mode) is
2102
+ // decided per path too.
1923
2103
  for (const f of files) writePolicy.assertPathWritable(ctx.sessionId, f.path);
1924
- for (const f of files) await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
1925
2104
  const fs = await ctx.getFilesystem(a.branch as string);
1926
2105
  // `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
1927
2106
  // batch as one commit. Structural cast avoids a workflow-internal import.
@@ -1950,6 +2129,18 @@ export function registerWorkspaceTools(
1950
2129
  for (const f of files) {
1951
2130
  const entry: Record<string, unknown> = { path: f.path };
1952
2131
  outcomes.push(entry);
2132
+ try {
2133
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, f.path);
2134
+ } catch (err) {
2135
+ // A DELIBERATE refusal by the deployment's write hook is this path's
2136
+ // outcome and no more: its message is what the caller is meant to
2137
+ // read, and one refused path must not take the others down. Anything
2138
+ // else the hook throws is not a verdict — it is the gate itself
2139
+ // failing — so `refuse` rethrows it and the whole batch fails loudly,
2140
+ // exactly as it does in `write_file`.
2141
+ refuse(entry, err);
2142
+ continue;
2143
+ }
1953
2144
  try {
1954
2145
  assertNotDocumentEdit(readers, f.path);
1955
2146
  await assertNotBinaryOverwrite(readers, f.path, fs);
@@ -2017,9 +2208,10 @@ export function registerWorkspaceTools(
2017
2208
 
2018
2209
  mount({
2019
2210
  name: 'edit_file',
2211
+ gated: true,
2020
2212
  description:
2021
2213
  'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
2022
- ONTOLOGY_BOUNDARY_NOTE,
2214
+ ESCAPE_SEQUENCE_NOTE,
2023
2215
  inputs: {
2024
2216
  type: 'object',
2025
2217
  properties: {
@@ -2043,7 +2235,7 @@ export function registerWorkspaceTools(
2043
2235
  handler: async (a, ctx: ToolContext) => {
2044
2236
  assertNotDocumentEdit(readers, a.path as string);
2045
2237
  writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
2046
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
2238
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
2047
2239
  const fs = await ctx.getFilesystem(a.branch as string);
2048
2240
  const path = a.path as string;
2049
2241
  const oldStr = a.old_string as string;
@@ -2067,10 +2259,10 @@ export function registerWorkspaceTools(
2067
2259
 
2068
2260
  mount({
2069
2261
  name: 'delete_file',
2262
+ gated: true,
2070
2263
  description: () =>
2071
2264
  'Delete ONE workspace file (a symbolic link is refused: links are never followed or removed). Committed + pushed as you. Its folder stays, even when this was its last file. Files only: a folder is refused with a pointer to `delete_folder`. ' +
2072
- `A platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) and git metadata are refused.` +
2073
- ONTOLOGY_BOUNDARY_NOTE,
2265
+ `A platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) and git metadata are refused.`,
2074
2266
  inputs: {
2075
2267
  type: 'object',
2076
2268
  properties: {
@@ -2089,14 +2281,14 @@ export function registerWorkspaceTools(
2089
2281
  write: true,
2090
2282
  proposable: true,
2091
2283
  handler: async (a, ctx: ToolContext) => {
2092
- // A delete propagates no cross-ontology information (it removes a node, it
2093
- // doesn't carry bytes from elsewhere), so it is NOT ontology-write-gated — it
2094
- // only records the ontology it touched, like a read. The extension policy
2095
- // DOES apply though: a dashboard-only run must not delete graph `.md` nodes.
2284
+ // A delete carries no bytes from anywhere else — it removes a node — so
2285
+ // it goes to the READ hook, like a read, not the write hook. The
2286
+ // extension policy DOES apply though: a dashboard-only run must not
2287
+ // delete graph `.md` nodes.
2096
2288
  const path = a.path as string;
2097
2289
  const branch = a.branch as string;
2098
2290
  writePolicy.assertPathWritable(ctx.sessionId, path);
2099
- await recordOntologyRead(sessionOntologyGate, ctx, path);
2291
+ await notifyAgentRead(agentAccessGate, ctx, branch, path);
2100
2292
  const fs = await ctx.getFilesystem(branch);
2101
2293
  assertPlainPath(path);
2102
2294
  const root = await workspaceRoot(branch, ctx);
@@ -2120,13 +2312,13 @@ export function registerWorkspaceTools(
2120
2312
 
2121
2313
  mount({
2122
2314
  name: 'delete_folder',
2315
+ gated: true,
2123
2316
  description:
2124
2317
  'Delete a workspace FOLDER and every file under it, at any depth; the whole folder lands as ONE committed + pushed change as you — all of it or none of it — then the empty folder is removed. This is the one way a folder goes away: the folder that held it stays, even if this was all it had, and a folder holding nothing but its empty-folder placeholder counts as empty. ' +
2125
2318
  'Preflight first: `dryRun: true` changes nothing and answers `{ path, kind: "folder", descendants, files, filesTruncated, allowed, reason? }` — `descendants` is the file count, `files` names up to 100 of them. ' +
2126
2319
  'A non-empty folder is deleted only with `confirm: true`; without it the call deletes nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm. ' +
2127
2320
  'Refused (in a dry run as `allowed: false` with the `reason`): a platform folder (the repository root or a reserved root folder such as `KnowledgeBase/`), git metadata, a folder holding a symbolic link (links are never removed), and a folder holding any file you may not write. A path that is a file is refused with a pointer to `delete_file`, and a path through a symbolic link is refused (links are never followed). ' +
2128
- 'The folder\'s own platform files (`access.md`, `.bevelignore`) go with it in that same one change, so its files are never left ungoverned part-way; you must be able to write those platform files too.' +
2129
- ONTOLOGY_BOUNDARY_NOTE,
2321
+ 'The folder\'s own platform files (`access.md`, `.bevelignore`) go with it in that same one change, so its files are never left ungoverned part-way; you must be able to write those platform files too.',
2130
2322
  inputs: {
2131
2323
  type: 'object',
2132
2324
  properties: {
@@ -2164,7 +2356,7 @@ export function registerWorkspaceTools(
2164
2356
  // The normaliser has already placed the path inside the repository; this
2165
2357
  // is the check that it really is in there before a folder is walked.
2166
2358
  assertInsideRepo(path, kbDirName);
2167
- await recordOntologyRead(sessionOntologyGate, ctx, path);
2359
+ await notifyAgentRead(agentAccessGate, ctx, branch, path);
2168
2360
  const fs = await ctx.getFilesystem(branch);
2169
2361
  const kind = await kindOf(fs, path);
2170
2362
  if (kind === null) throw new ToolError(`"${path}" does not exist.`, 404);
@@ -2259,7 +2451,8 @@ export function registerWorkspaceTools(
2259
2451
 
2260
2452
  mount({
2261
2453
  name: 'mkdir',
2262
- description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.' + ONTOLOGY_BOUNDARY_NOTE,
2454
+ gated: true,
2455
+ description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.',
2263
2456
  inputs: {
2264
2457
  type: 'object',
2265
2458
  properties: {
@@ -2279,7 +2472,7 @@ export function registerWorkspaceTools(
2279
2472
  proposable: true,
2280
2473
  handler: async (a, ctx: ToolContext) => {
2281
2474
  writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
2282
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
2475
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
2283
2476
  await (await ctx.getFilesystem(a.branch as string)).mkdir(a.path as string, { recursive: true });
2284
2477
  return { path: a.path, created: true };
2285
2478
  },
@@ -2287,12 +2480,12 @@ export function registerWorkspaceTools(
2287
2480
 
2288
2481
  mount({
2289
2482
  name: 'move_file',
2483
+ gated: true,
2290
2484
  description: () =>
2291
2485
  'Move or rename a workspace FILE or FOLDER; a folder moves recursively, with everything under it. `dest` is the full new path, not the folder to move into. Lands as a delete + create, committed + pushed as you. ' +
2292
2486
  `Rules: the destination must not exist — a move never overwrites a file or merges into a folder; a platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) is refused with "<name> is a platform file and stays in its folder." — a folder that moves takes its own platform files along, still in their folder; a platform folder (the repository root or a reserved root folder such as \`KnowledgeBase/\`) and git metadata are refused; a move cannot create a platform file or folder at \`dest\` either (renaming a note to \`access.md\` is refused); a path through a symbolic link is refused, since links are never followed; on a protected branch you must be able to write both ends — for a folder, every file under it at its old and its new path. ` +
2293
- 'Access follows the destination folder. Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination. ' +
2294
- 'A move whose `accessChanges` is true runs only with `confirm: true`; without it the call moves nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm.' +
2295
- ONTOLOGY_BOUNDARY_NOTE,
2487
+ 'Access follows the destination folder. Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the move has landed, with every `access.md` inside a moved folder counted at its new place. ' +
2488
+ 'A move whose `accessChanges` is true runs only with `confirm: true`; without it the call moves nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm.',
2296
2489
  inputs: {
2297
2490
  type: 'object',
2298
2491
  properties: {
@@ -2313,8 +2506,8 @@ export function registerWorkspaceTools(
2313
2506
  dest: str('Destination path (echoes the input).'),
2314
2507
  kind: str('`file` or `folder`.'),
2315
2508
  descendants: int('Files that move: 1 for a file, the file count under a folder.'),
2316
- access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and destination (`after`).' },
2317
- accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between source and destination.' },
2509
+ access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the move has landed (`after`).' },
2510
+ accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after`.' },
2318
2511
  allowed: { type: 'boolean', description: 'Whether the move may run.' },
2319
2512
  reason: str('Why it may not, when `allowed` is false.'),
2320
2513
  dryRun: { type: 'boolean', description: 'True on a dry run.' },
@@ -2332,12 +2525,12 @@ export function registerWorkspaceTools(
2332
2525
  const src = (a.src as string).replace(/\/+$/, '');
2333
2526
  const dest = (a.dest as string).replace(/\/+$/, '');
2334
2527
  const branch = a.branch as string;
2335
- // A move CARRIES the source content into the destination — a genuine
2336
- // cross-ontology flow if the two differ — so BOTH endpoints are write-gated
2337
- // (unlike a plain delete, which moves no content). Check both BEFORE
2338
- // touching disk so a blocked endpoint can't leave the source already deleted.
2339
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, src);
2340
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, dest);
2528
+ // A move CARRIES the source content into the destination, so BOTH ends
2529
+ // go to the write hook (unlike a plain delete, which moves no content).
2530
+ // Ask about both BEFORE touching disk, so a refused end can't leave the
2531
+ // source already deleted.
2532
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, src);
2533
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, dest);
2341
2534
  const fs = await ctx.getFilesystem(branch);
2342
2535
  const root = await workspaceRoot(branch, ctx);
2343
2536
  // Before the kind check, which stats THROUGH a link: a dangling link at
@@ -2354,8 +2547,13 @@ export function registerWorkspaceTools(
2354
2547
  writePolicy.assertPathWritable(ctx.sessionId, dest + f.slice(src.length));
2355
2548
  }
2356
2549
 
2357
- const [before, after] = await Promise.all([accessAt(branch, ctx, src), accessAt(branch, ctx, dest)]);
2358
- const accessChanges = (Object.keys(before) as (keyof AccessVerbs)[]).some((v) => before[v] !== after[v]);
2550
+ // `after` is the destination as it WILL be — with the `access.md` files
2551
+ // under `src` counted where they land. See `accessAfter`.
2552
+ const [before, after] = await Promise.all([
2553
+ accessAt(branch, ctx, src),
2554
+ accessAfter(branch, ctx, src, dest),
2555
+ ]);
2556
+ const accessChanges = verbsDiffer(before, after);
2359
2557
  // The placeholder moves with its folder, but it is never content.
2360
2558
  const descendants = srcFiles.filter((f) => !isFolderPlaceholder(f)).length;
2361
2559
  // Neither end may be the platform's own: a move neither takes a platform
@@ -2448,15 +2646,18 @@ export function registerWorkspaceTools(
2448
2646
 
2449
2647
  mount({
2450
2648
  name: 'copy_file',
2649
+ gated: true,
2451
2650
  description:
2452
- 'Copy a workspace file to a new path. The destination must not exist — like a move, a copy never overwrites a file or a folder; to change what is in a file that already exists, write it. Committed + pushed as you.'
2453
- + ONTOLOGY_BOUNDARY_NOTE,
2651
+ 'Copy a workspace FILE to a new path. The destination must not exist — like a move, a copy never overwrites a file or a folder; to change what is in a file that already exists, write it. Committed + pushed as you. ' +
2652
+ 'Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the copy has landed, with every `access.md` inside a copied folder counted at its new place. ' +
2653
+ 'One exception to that shape: when the destination is one you may not write, the answer is the refusal alone — `allowed: false` with `reason`, and no `kind` and no `descendants`, because nothing about the source is read before that verdict.',
2454
2654
  inputs: {
2455
2655
  type: 'object',
2456
2656
  properties: {
2457
2657
  branch: BRANCH_INPUT,
2458
2658
  src: wsPath(kbDirName, 'Source path'),
2459
2659
  dest: wsPath(kbDirName, 'Destination path — must not exist yet'),
2660
+ dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
2460
2661
  sessionId: SESSION_ID_INPUT,
2461
2662
  },
2462
2663
  required: ['branch', 'src', 'dest'],
@@ -2464,20 +2665,30 @@ export function registerWorkspaceTools(
2464
2665
  },
2465
2666
  outputs: {
2466
2667
  type: 'object',
2467
- properties: { src: str('Source path (echoes the input).'), dest: str('Destination path (echoes the input).'), copied: { type: 'boolean', description: 'Always true on success.' } },
2668
+ properties: {
2669
+ src: str('Source path (echoes the input).'),
2670
+ dest: str('Destination path (echoes the input).'),
2671
+ kind: str('`file` or `folder` (dry run only; absent when `allowed` is false because you may not write the destination).'),
2672
+ descendants: int('Files the copy would carry: 1 for a file, the file count under a folder (dry run only; absent when `allowed` is false because you may not write the destination).'),
2673
+ access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the copy has landed (`after`) — dry run only.' },
2674
+ accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after` (dry run only).' },
2675
+ allowed: { type: 'boolean', description: 'Whether the copy may run (dry run only).' },
2676
+ reason: str('Why it may not, when `allowed` is false.'),
2677
+ dryRun: { type: 'boolean', description: 'True on a dry run.' },
2678
+ copied: { type: 'boolean', description: 'True once the copy landed; false on a dry run.' },
2679
+ },
2468
2680
  required: ['src', 'dest', 'copied'],
2469
2681
  },
2470
2682
  write: true,
2471
2683
  proposable: true,
2472
2684
  handler: async (a, ctx: ToolContext) => {
2473
- // A copy CARRIES the source content into the destination — a genuine
2474
- // cross-ontology flow if the two differ — so BOTH endpoints are write-gated.
2475
- // Check both before touching disk.
2685
+ // A copy CARRIES the source content into the destination, so BOTH ends
2686
+ // go to the write hook. Ask about both before touching disk.
2476
2687
  writePolicy.assertPathWritable(ctx.sessionId, a.src as string);
2477
2688
  writePolicy.assertPathWritable(ctx.sessionId, a.dest as string);
2478
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src as string);
2479
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest as string);
2480
2689
  const branch = a.branch as string;
2690
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.src as string);
2691
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.dest as string);
2481
2692
  const src = a.src as string;
2482
2693
  const dest = a.dest as string;
2483
2694
  // A copy lands bytes at a name of its own, so it is refused by the same
@@ -2489,6 +2700,7 @@ export function registerWorkspaceTools(
2489
2700
  // own containment check: a path with a `..` segment must not reach
2490
2701
  // `lstat` outside the workspace, even to be told a name is taken.
2491
2702
  assertPlainPath(dest);
2703
+ if (a.dryRun === true) return copyImpact(branch, ctx, src, dest);
2492
2704
  // The write verdict comes FIRST, for the reason `move_file` gives at
2493
2705
  // length: "already exists" is a fact about the destination folder, and a
2494
2706
  // caller who may not write there must not be told it. The lock gate
@@ -2525,6 +2737,12 @@ export function registerWorkspaceTools(
2525
2737
  try {
2526
2738
  await asEntryExists(() => fs.copyFile(src, dest));
2527
2739
  } catch (err) {
2740
+ // The filesystem's own "that is a directory" becomes the sentence the
2741
+ // dry run predicts, instead of escaping as a 500 carrying the
2742
+ // server's absolute path.
2743
+ if ((err as { name?: string } | null)?.name === 'IsDirectoryError') {
2744
+ throw new ToolError(folderCopyRefusal(src), 400);
2745
+ }
2528
2746
  const missing = isAbsence(err) || (err as { name?: string }).name === 'FileNotFoundError';
2529
2747
  if (missing) {
2530
2748
  throw (await kindOf(fs, src)) === null
@@ -2539,9 +2757,9 @@ export function registerWorkspaceTools(
2539
2757
 
2540
2758
  mount({
2541
2759
  name: 'unzip',
2760
+ gated: true,
2542
2761
  description:
2543
- 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.' +
2544
- ONTOLOGY_BOUNDARY_NOTE,
2762
+ 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.',
2545
2763
  inputs: {
2546
2764
  type: 'object',
2547
2765
  properties: {
@@ -2573,9 +2791,9 @@ export function registerWorkspaceTools(
2573
2791
  write: true,
2574
2792
  handler: async (a, ctx: ToolContext) => {
2575
2793
  const zipPath = a.path as string;
2576
- // Reading the source archive pins/records the source ontology, so a session
2577
- // can't unzip from ontology A into ontology B without the A read counting.
2578
- await recordOntologyRead(sessionOntologyGate, ctx, zipPath);
2794
+ // Opening the archive is a read of the archive, so the read hook hears
2795
+ // about it before a single entry is extracted out of it.
2796
+ await notifyAgentRead(agentAccessGate, ctx, a.branch as string, zipPath);
2579
2797
  // A .zip that is not there is a missing PATH, not an unreadable archive:
2580
2798
  // the service now says so (PathNotFoundError) and the helper turns it
2581
2799
  // into the same 404 every other file tool answers. Only that declared
@@ -2587,10 +2805,10 @@ export function registerWorkspaceTools(
2587
2805
  workspaceIdForBranch(a.branch as string),
2588
2806
  zipPath,
2589
2807
  typeof a.destination === 'string' ? a.destination : undefined,
2590
- // Each extracted file is a write: a cross-ontology or write-blocked entry
2591
- // is skipped (not extracted), so an archive can't bypass the boundary — the
2592
- // extension policy applies per entry too, so a restricted run can't unzip a
2593
- // `.md` into the graph.
2808
+ // Each extracted file is a write of its own: an entry the write
2809
+ // hook refuses is skipped (not extracted), so an archive can't be
2810
+ // a way around it — the extension policy applies per entry too, so
2811
+ // a restricted run can't unzip a `.md` into the graph.
2594
2812
  (wsRelPath) => {
2595
2813
  // An entry that would land beside the repository is skipped with the
2596
2814
  // corrected-path reason, like any other refused entry.
@@ -2604,7 +2822,7 @@ export function registerWorkspaceTools(
2604
2822
  );
2605
2823
  }
2606
2824
  writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
2607
- return assertOntologyWriteAllowed(sessionOntologyGate, ctx, wsRelPath);
2825
+ return assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, wsRelPath);
2608
2826
  },
2609
2827
  ),
2610
2828
  'Nothing to extract',
@@ -2615,9 +2833,9 @@ export function registerWorkspaceTools(
2615
2833
  // ── shell (internal-only) ───────────────────────────────────────────────
2616
2834
  mount({
2617
2835
  name: 'execute_command',
2836
+ gated: true,
2618
2837
  description:
2619
- 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.' +
2620
- ONTOLOGY_BOUNDARY_NOTE,
2838
+ 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.',
2621
2839
  internalOnly: true,
2622
2840
  fileTool: false,
2623
2841
  // The one tool the mount's branch check skips: the handler below resolves an
@@ -2714,11 +2932,12 @@ export function registerWorkspaceTools(
2714
2932
  400,
2715
2933
  );
2716
2934
  }
2717
- // Shell is a write path with no single target path to check, so enforce the
2718
- // boundary at the session level: refuse once the run is already write-blocked,
2719
- // or when the run is restricted to a file type (shell could write anything).
2935
+ // Shell is a write path with no single target path to check, so the
2936
+ // write hook is asked once for the call itself, with no path — and the
2937
+ // run must not be restricted to a file type either, since shell could
2938
+ // write anything.
2720
2939
  writePolicy.assertUnrestricted(ctx.sessionId);
2721
- await assertShellAllowedWithinOntology(sessionOntologyGate, ctx);
2940
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
2722
2941
  // Canonical per-branch bootstrap entry point — it owns the workspace-id
2723
2942
  // encoding and the single-flight clone, so the shell never derives a
2724
2943
  // workspace path by hand.