@bevel-software/platform-core-backend 0.21.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 (434) 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 +62 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +22 -3
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +158 -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-requests.contract.d.ts +75 -0
  31. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  32. package/dist/modules/access/access-requests.contract.js +20 -0
  33. package/dist/modules/access/access-requests.contract.js.map +1 -0
  34. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  35. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  36. package/dist/modules/access/access-requests.routes.js +237 -0
  37. package/dist/modules/access/access-requests.routes.js.map +1 -0
  38. package/dist/modules/access/access-requests.service.d.ts +123 -0
  39. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-requests.service.js +337 -0
  41. package/dist/modules/access/access-requests.service.js.map +1 -0
  42. package/dist/modules/access/access.routes.d.ts.map +1 -1
  43. package/dist/modules/access/access.routes.js +13 -15
  44. package/dist/modules/access/access.routes.js.map +1 -1
  45. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  46. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  47. package/dist/modules/access-model/access-grammar.js +24 -0
  48. package/dist/modules/access-model/access-grammar.js.map +1 -1
  49. package/dist/modules/auth/account-admission.d.ts +58 -7
  50. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  51. package/dist/modules/auth/account-admission.js +44 -1
  52. package/dist/modules/auth/account-admission.js.map +1 -1
  53. package/dist/modules/auth/account.routes.d.ts +1 -1
  54. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  55. package/dist/modules/auth/account.routes.js +61 -5
  56. package/dist/modules/auth/account.routes.js.map +1 -1
  57. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  58. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  59. package/dist/modules/auth/auth.middleware.js +18 -7
  60. package/dist/modules/auth/auth.middleware.js.map +1 -1
  61. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  62. package/dist/modules/auth/auth.routes.js +8 -0
  63. package/dist/modules/auth/auth.routes.js.map +1 -1
  64. package/dist/modules/auth/auth.service.d.ts +96 -3
  65. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  66. package/dist/modules/auth/auth.service.js +187 -9
  67. package/dist/modules/auth/auth.service.js.map +1 -1
  68. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  69. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  70. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  71. package/dist/modules/database/core-schema.d.ts +41 -24
  72. package/dist/modules/database/core-schema.d.ts.map +1 -1
  73. package/dist/modules/database/core-schema.js +35 -27
  74. package/dist/modules/database/core-schema.js.map +1 -1
  75. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  76. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  77. package/dist/modules/github-app/github-app.client.js +222 -0
  78. package/dist/modules/github-app/github-app.client.js.map +1 -0
  79. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  80. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  81. package/dist/modules/github-app/github-app.connection.js +185 -0
  82. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  83. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  84. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  85. package/dist/modules/github-app/github-app.routes.js +323 -0
  86. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  87. package/dist/modules/github-app/index.d.ts +4 -0
  88. package/dist/modules/github-app/index.d.ts.map +1 -0
  89. package/dist/modules/github-app/index.js +4 -0
  90. package/dist/modules/github-app/index.js.map +1 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  92. package/dist/modules/kb-fs/branch-name.js +12 -2
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  94. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  95. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/remote-url.js +35 -0
  97. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  98. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  99. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  100. package/dist/modules/kb-fs/repo-path.js +79 -0
  101. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  102. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  104. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  105. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  106. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  108. package/dist/modules/kb-sync/sync-auth.js +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  110. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  112. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  113. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  114. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  115. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  116. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  117. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  118. package/dist/modules/plugins/join-proposals.js +76 -22
  119. package/dist/modules/plugins/join-proposals.js.map +1 -1
  120. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  121. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-requests.service.js +173 -29
  123. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  124. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  125. package/dist/modules/plugins/plugins.routes.js +3 -2
  126. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  127. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  128. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  129. package/dist/modules/settings/deployment-settings.service.js +101 -7
  130. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  131. package/dist/modules/settings/managed-repository.d.ts +43 -0
  132. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  133. package/dist/modules/settings/managed-repository.js +60 -0
  134. package/dist/modules/settings/managed-repository.js.map +1 -0
  135. package/dist/modules/settings/repository-source.d.ts +128 -0
  136. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  137. package/dist/modules/settings/repository-source.js +150 -0
  138. package/dist/modules/settings/repository-source.js.map +1 -0
  139. package/dist/modules/settings/setup.routes.d.ts +78 -4
  140. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  141. package/dist/modules/settings/setup.routes.js +379 -17
  142. package/dist/modules/settings/setup.routes.js.map +1 -1
  143. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  144. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  145. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  146. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  147. package/dist/modules/skills/skills.contract.d.ts +90 -18
  148. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  149. package/dist/modules/skills/skills.contract.js +4 -1
  150. package/dist/modules/skills/skills.contract.js.map +1 -1
  151. package/dist/modules/skills/skills.service.d.ts +98 -9
  152. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  153. package/dist/modules/skills/skills.service.js +239 -37
  154. package/dist/modules/skills/skills.service.js.map +1 -1
  155. package/dist/modules/skills/skills.tools.d.ts +7 -0
  156. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  157. package/dist/modules/skills/skills.tools.js +71 -7
  158. package/dist/modules/skills/skills.tools.js.map +1 -1
  159. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  160. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  161. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  162. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  163. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  164. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  165. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  166. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  167. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  168. package/dist/modules/tool-helpers/tool-context.js +15 -0
  169. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  170. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  171. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  172. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  173. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  174. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  175. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  176. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  177. package/dist/modules/tool-helpers/validate-token.js +1 -1
  178. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  179. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  180. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  181. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  182. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  183. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  184. package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
  185. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  186. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  187. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  188. package/dist/modules/workflow/file-lock.service.js +30 -0
  189. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  190. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  191. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  192. package/dist/modules/workflow/git/git.service.js +170 -6
  193. package/dist/modules/workflow/git/git.service.js.map +1 -1
  194. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  195. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  196. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  197. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  198. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  199. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  200. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  201. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  202. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  203. package/dist/modules/workflow/pending-commits.service.js +55 -0
  204. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  205. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  206. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  207. package/dist/modules/workflow/workflow-hooks.js +16 -1
  208. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  209. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  210. package/dist/modules/workflow/workflow.routes.js +3 -1
  211. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  212. package/dist/modules/workflow/workflow.service.d.ts +94 -13
  213. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  214. package/dist/modules/workflow/workflow.service.js +258 -51
  215. package/dist/modules/workflow/workflow.service.js.map +1 -1
  216. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  217. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  218. package/dist/modules/workspace/agent-access.gate.js +123 -0
  219. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  220. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  221. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  222. package/dist/modules/workspace/routine-write-policy.js +5 -6
  223. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  224. package/dist/modules/workspace/session-sink.d.ts +5 -5
  225. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  226. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  227. package/dist/modules/workspace/set-aside-clone.js +92 -0
  228. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  229. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
  230. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
  232. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +92 -3
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +272 -26
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  241. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  242. package/dist/modules/workspace/workspace.tools.js +337 -117
  243. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  244. package/dist/modules/write-access/write-access.d.ts +60 -0
  245. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  246. package/dist/modules/write-access/write-access.js +129 -0
  247. package/dist/modules/write-access/write-access.js.map +1 -0
  248. package/dist/shared/domain-errors.d.ts +64 -0
  249. package/dist/shared/domain-errors.d.ts.map +1 -1
  250. package/dist/shared/domain-errors.js +83 -0
  251. package/dist/shared/domain-errors.js.map +1 -1
  252. package/dist/shared/git.contract.d.ts +30 -0
  253. package/dist/shared/git.contract.d.ts.map +1 -1
  254. package/dist/shared/git.contract.js +26 -0
  255. package/dist/shared/git.contract.js.map +1 -1
  256. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  257. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  258. package/dist/tenancy/static-tenant-source.js +0 -2
  259. package/dist/tenancy/static-tenant-source.js.map +1 -1
  260. package/migrations/0014_change_request_closed_reason.sql +1 -0
  261. package/migrations/0015_account_deactivation.sql +3 -0
  262. package/migrations/meta/0014_snapshot.json +2265 -0
  263. package/migrations/meta/0015_snapshot.json +2271 -0
  264. package/migrations/meta/_journal.json +14 -0
  265. package/package.json +3 -3
  266. package/src/__tests__/kb-layout-config.test.ts +0 -2
  267. package/src/__tests__/retired-settings.test.ts +96 -0
  268. package/src/core/__tests__/gated-boot.test.ts +132 -0
  269. package/src/core/__tests__/lifecycle.test.ts +170 -1
  270. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  271. package/src/core/core-ports.ts +11 -1
  272. package/src/core/create-core-server.ts +69 -15
  273. package/src/core/create-core-services.ts +190 -27
  274. package/src/core/lifecycle.ts +98 -13
  275. package/src/core-config.ts +13 -14
  276. package/src/index.ts +12 -1
  277. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  278. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  279. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  280. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  281. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  282. package/src/modules/access/access-control.interface.ts +43 -12
  283. package/src/modules/access/access-control.service.ts +234 -36
  284. package/src/modules/access/access-requests.contract.ts +93 -0
  285. package/src/modules/access/access-requests.routes.ts +286 -0
  286. package/src/modules/access/access-requests.service.ts +420 -0
  287. package/src/modules/access/access.routes.ts +13 -15
  288. package/src/modules/access-model/access-grammar.ts +22 -0
  289. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  290. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  291. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  292. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  293. package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
  294. package/src/modules/auth/account-admission.ts +78 -9
  295. package/src/modules/auth/account.routes.ts +63 -7
  296. package/src/modules/auth/auth.middleware.ts +19 -8
  297. package/src/modules/auth/auth.routes.ts +8 -0
  298. package/src/modules/auth/auth.service.ts +207 -7
  299. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  300. package/src/modules/database/core-schema.ts +35 -27
  301. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  302. package/src/modules/github-app/github-app.client.ts +268 -0
  303. package/src/modules/github-app/github-app.connection.ts +204 -0
  304. package/src/modules/github-app/github-app.routes.ts +359 -0
  305. package/src/modules/github-app/index.ts +19 -0
  306. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  307. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  308. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  309. package/src/modules/kb-fs/branch-name.ts +14 -1
  310. package/src/modules/kb-fs/remote-url.ts +35 -0
  311. package/src/modules/kb-fs/repo-path.ts +85 -0
  312. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  313. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  314. package/src/modules/kb-sync/sync-auth.ts +2 -2
  315. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  316. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  317. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  318. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  319. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  320. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  321. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  322. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  323. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  324. package/src/modules/plugins/join-proposals.ts +87 -19
  325. package/src/modules/plugins/join-requests.service.ts +199 -39
  326. package/src/modules/plugins/plugins.routes.ts +3 -2
  327. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  328. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  329. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
  330. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
  331. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
  332. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  333. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  334. package/src/modules/settings/deployment-settings.service.ts +117 -6
  335. package/src/modules/settings/managed-repository.ts +68 -0
  336. package/src/modules/settings/repository-source.ts +197 -0
  337. package/src/modules/settings/setup.routes.ts +463 -15
  338. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  339. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  341. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  342. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  343. package/src/modules/skills/skills.contract.ts +91 -18
  344. package/src/modules/skills/skills.service.ts +278 -41
  345. package/src/modules/skills/skills.tools.ts +80 -8
  346. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  347. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  348. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  349. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  350. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  351. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  352. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  353. package/src/modules/tool-helpers/tool-context.ts +15 -0
  354. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  355. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  356. package/src/modules/tool-helpers/validate-token.ts +1 -1
  357. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  358. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  359. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  360. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  361. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  362. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  363. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
  364. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  365. package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
  366. package/src/modules/workflow/file-lock.service.ts +31 -0
  367. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  368. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  369. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  370. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  371. package/src/modules/workflow/git/git.service.ts +193 -4
  372. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  373. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  374. package/src/modules/workflow/pending-commits.service.ts +59 -1
  375. package/src/modules/workflow/workflow-hooks.ts +64 -26
  376. package/src/modules/workflow/workflow.routes.ts +3 -1
  377. package/src/modules/workflow/workflow.service.ts +290 -55
  378. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  379. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  380. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  381. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  382. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  383. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  384. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  385. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  386. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  387. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  388. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  389. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  390. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
  391. package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
  392. package/src/modules/workspace/agent-access.gate.ts +164 -0
  393. package/src/modules/workspace/routine-write-policy.ts +5 -6
  394. package/src/modules/workspace/session-sink.ts +5 -5
  395. package/src/modules/workspace/set-aside-clone.ts +96 -0
  396. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
  397. package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
  398. package/src/modules/workspace/workspace.routes.ts +95 -3
  399. package/src/modules/workspace/workspace.service.ts +273 -25
  400. package/src/modules/workspace/workspace.tools.ts +373 -119
  401. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  402. package/src/modules/write-access/write-access.ts +153 -0
  403. package/src/shared/domain-errors.ts +89 -0
  404. package/src/shared/git.contract.ts +37 -0
  405. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  406. package/src/tenancy/static-tenant-source.ts +0 -3
  407. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  408. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  409. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  410. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  411. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  412. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  413. package/dist/modules/workflow/session-ontology.service.js +0 -147
  414. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  415. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  416. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  417. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  418. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  419. package/dist/shared/kb-layout.d.ts +0 -39
  420. package/dist/shared/kb-layout.d.ts.map +0 -1
  421. package/dist/shared/kb-layout.js +0 -103
  422. package/dist/shared/kb-layout.js.map +0 -1
  423. package/dist/shared/kb-layout.test.d.ts +0 -2
  424. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  425. package/dist/shared/kb-layout.test.js +0 -75
  426. package/dist/shared/kb-layout.test.js.map +0 -1
  427. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  428. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  429. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  430. package/src/modules/workflow/session-ontology.service.ts +0 -183
  431. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  432. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  433. package/src/shared/kb-layout.test.ts +0 -98
  434. package/src/shared/kb-layout.ts +0 -102
@@ -3,9 +3,10 @@ import nodeFs from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { ToolError } from '../tool-helpers/tool.contract.js';
5
5
  import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
6
- import { recordOntologyRead, assertOntologyWriteAllowed, assertShellAllowedWithinOntology, ONTOLOGY_BOUNDARY_NOTE, SESSION_ID_INPUT, } from './session-ontology.gate.js';
6
+ import { notifyAgentRead, assertAgentWriteAllowed, SESSION_ID_INPUT, } from './agent-access.gate.js';
7
7
  import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
8
8
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
9
+ import { assertBranchProvided } from '../../shared/domain-errors.js';
9
10
  // Leaf-level shared primitive (same exception `workspace.service.ts` already
10
11
  // relies on) — not a workflow service, so this stays inside the module boundary.
11
12
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
@@ -318,6 +319,25 @@ const WRITE_MODE_NOTE = ' `mode` decides what may happen at a path and DEFAULTS
318
319
  'already exists (`exists`, with the path — pass `mode: overwrite` to replace it), `overwrite` replaces what is there ' +
319
320
  '(creating it if there is nothing), `update` replaces an existing file and refuses a path that does not exist (`missing`). ' +
320
321
  'A refused path is left exactly as it was.';
322
+ /**
323
+ * What an agent needs to know about escape sequences in the content it sends,
324
+ * on the three tools that take content as a JSON string.
325
+ *
326
+ * The three write routes — the MCP endpoint, the `/api/agent/tools/<name>`
327
+ * route and `call_tool_chain` — were measured end to end against raw requests
328
+ * and a byte-level read of the stored file (see
329
+ * `__tests__/escape-sequences.routes.test.ts`): each stores content exactly as
330
+ * the JSON string value decodes ONCE. So when an escape arrives already
331
+ * decoded, the decoding happened in the client that built the request, and no
332
+ * tool here can tell that content from content that was meant to be decoded.
333
+ * Hence a warning rather than a fix, and the pointer to the one route whose
334
+ * payload is bytes rather than a JSON string.
335
+ */
336
+ const ESCAPE_SEQUENCE_NOTE = ' Escape sequences: some clients decode them in arguments before sending, so content meant to CONTAIN an escape rather ' +
337
+ 'than what it stands for (the six characters backslash, `u`, `0`, `0`, `4`, `1`, say, rather than the letter `A`) can ' +
338
+ 'reach this tool already decoded — what arrives is stored byte for byte, so when that distinction matters, verify what ' +
339
+ 'landed (`read_file`, or a hash) and send such content through the upload route (`request_upload_token` + `apply_upload` ' +
340
+ 'where offered, otherwise Upload in the app), which lands it unchanged.';
321
341
  /** The refusal `create` gives on a path that already holds something. */
322
342
  function pathExists(path) {
323
343
  return new ToolError(`"${displayPath(path)}" already exists — pass mode: overwrite to replace it, or write to a different path.`, 409, { code: 'exists', path });
@@ -430,7 +450,7 @@ async function searchRootKind(fs, path) {
430
450
  }
431
451
  }
432
452
  /** JS grep over the workspace tree (read methods only) — bounded by match + depth caps. */
433
- async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead, docs) {
453
+ async function grepWalk(fs, dir, re, out, max, depth, gate, notifyRead, docs) {
434
454
  if (out.length >= max || depth > 12)
435
455
  return;
436
456
  let entries;
@@ -452,13 +472,13 @@ async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead,
452
472
  continue;
453
473
  const p = dir ? `${dir}/${e.name}` : e.name;
454
474
  if (e.type === 'directory') {
455
- await grepWalk(fs, p, re, out, max, depth + 1, gate, recordOntologyRead, docs);
475
+ await grepWalk(fs, p, re, out, max, depth + 1, gate, notifyRead, docs);
456
476
  }
457
477
  else {
458
- // Opening a file under a named ontology is a read of that ontology — even
459
- // for a root-level grep that resolves to a neutral root. Record it so a
460
- // cross-ontology grep poisons later writes (closes the read-leak).
461
- await recordOntologyRead(p);
478
+ // Opening a file is a read of it, even when the walk started at a root
479
+ // the read hook was already told about — so every file the walk opens
480
+ // reaches the hook by name (closes the read-leak).
481
+ await notifyRead(p);
462
482
  // A file the walk cannot read is silently skipped: one unreadable entry
463
483
  // must not fail a search over the whole tree.
464
484
  try {
@@ -488,6 +508,17 @@ const BATCH_SAVE_WARNINGS_OUTPUT = {
488
508
  'The writes still happened.',
489
509
  items: { type: 'object' },
490
510
  };
511
+ /**
512
+ * The `sessionId` property inside a BUILT tool def's input schema, or
513
+ * `undefined` for a tool that declares none. `toolDef` wraps the flat inputs
514
+ * under `body` and copies the schema it is given, so a note registered after
515
+ * the tools were built has to be written here rather than onto the shared
516
+ * `SESSION_ID_INPUT` constant.
517
+ */
518
+ function sessionIdInputOf(def) {
519
+ const inputs = def.inputs;
520
+ return inputs?.properties?.body?.properties?.sessionId;
521
+ }
491
522
  /**
492
523
  * Workspace domain tools: the file primitives (replacing Mastra's auto-injected
493
524
  * Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
@@ -497,7 +528,7 @@ const BATCH_SAVE_WARNINGS_OUTPUT = {
497
528
  * method), so they're implemented here. File ops are `both`; `execute_command`
498
529
  * is INTERNAL-only (arbitrary shell as the caller is too dangerous to expose).
499
530
  */
500
- export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kb, sessionOntologyGate, writePolicy, sessionSink,
531
+ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kb, agentAccessGate, writePolicy, sessionSink,
501
532
  /**
502
533
  * Save-time skill check (see `AllowedToolsChecker`): a write to a SKILL.md
503
534
  * returns `warnings` for `allowed-tools` entries naming no visible tool.
@@ -581,7 +612,7 @@ changeGate) {
581
612
  * Every spelling first, then the resolved form against the branch's
582
613
  * workspace, so a link into the folder is refused the same way. The resolved
583
614
  * check only runs on a branch that is already cloned: bootstrapping a clone
584
- * here would happen before the handler's access and ontology gates. A branch
615
+ * here would happen before the handler's access and agent-access gates. A branch
585
616
  * not cloned yet (or that does not resolve) is left to the handler; the
586
617
  * filesystem refuses again underneath regardless.
587
618
  */
@@ -604,7 +635,7 @@ changeGate) {
604
635
  * The branch's workspace root, to judge a spelling against what is on disk
605
636
  * — or null when there is nothing to judge it against yet. Only a branch
606
637
  * ALREADY cloned is used: bootstrapping one here would clone before the
607
- * handler's access and ontology gates have had their say.
638
+ * handler's access and agent-access gates have had their say.
608
639
  */
609
640
  const gitCheckRootFor = async (args, ctx) => {
610
641
  if (typeof args.branch !== 'string' || args.branch === '')
@@ -671,6 +702,112 @@ changeGate) {
671
702
  ]);
672
703
  return { read, write, download, owner };
673
704
  };
705
+ /** Whether any one of the caller's four verdicts differs between the two sides of a preview. */
706
+ const verbsDiffer = (before, after) => Object.keys(before).some((v) => before[v] !== after[v]);
707
+ /**
708
+ * Why `copy_file` will not take a folder. One sentence, said by the dry
709
+ * run and by the call itself, so the preflight and the execution never
710
+ * disagree — the rule this whole section is built on.
711
+ */
712
+ const folderCopyRefusal = (src) => `"${src}" is a folder; copy_file copies one file. Copy its files one by one, or move the folder with move_file.`;
713
+ /**
714
+ * The caller's verdicts at `dest` as they will be once `src` has been
715
+ * moved (or, with `sourceRemains`, copied) there — the `after` half of a
716
+ * move's or copy's preview.
717
+ *
718
+ * `accessAt(dest)` is the wrong answer to that question for a folder: the
719
+ * destination on disk has neither the folder nor the `access.md` files it
720
+ * carries, so it describes the destination's PARENT. A rename of a folder
721
+ * that names the caller owner in its own `access.md` therefore warned
722
+ * about losing owner access the move was about to hand straight back, and
723
+ * a warning that is wrong is a warning people learn to click through.
724
+ *
725
+ * Preview only, like everything else in this section: it answers what the
726
+ * caller WILL have, never whether they may do it. The write verdicts that
727
+ * gate the move are `writeBlocked` and the lock gate, both of which read
728
+ * the tree as it is.
729
+ */
730
+ const accessAfter = async (branch, ctx, src, dest, opts) => {
731
+ const from = toKbRelative(src, kbDirName);
732
+ const to = toKbRelative(dest, kbDirName);
733
+ // Outside the repository there are no rules to carry, and none to land
734
+ // among — the same answer `accessAt` gives for such a path.
735
+ if (from === null || to === null)
736
+ return accessAt(branch, ctx, dest);
737
+ return accessControl.previewAccessAfterRelocation(workspaceIdForBranch(branch), ctx.user.email, from, to, opts);
738
+ };
739
+ /**
740
+ * What `copy_file`'s dry run answers: the same impact shape `move_file`
741
+ * previews, over a copy's own rules.
742
+ *
743
+ * A copy LEAVES the source where it is, so the rules it carries are
744
+ * duplicated rather than relocated (`sourceRemains`) — otherwise the two
745
+ * previews ask the same question. The order of the refusals is `copy_file`'s
746
+ * own and is load-bearing: the write verdict on the destination outranks
747
+ * "that name is taken", because a caller who may not write a folder must
748
+ * not learn what is in it from a refusal.
749
+ *
750
+ * A folder source is reported as the refusal it is. `copy_file` copies one
751
+ * file; the preview says so rather than promising a copy that would fail,
752
+ * and still answers `access.after` for the folder it was asked about.
753
+ *
754
+ * NOTHING is probed on disk until the write verdict on the destination has
755
+ * been taken — not the destination, and not the source either, which is the
756
+ * order the call itself keeps at length: a caller who may not write there
757
+ * gets the same refusal whether the source is a file, a folder, or missing
758
+ * altogether. Probing the source first put a 404 in front of that 403 and
759
+ * handed a denied caller the source's kind and its file count. So a refused
760
+ * preview answers `allowed: false` with the sentence and no `kind` or
761
+ * `descendants`: those are the half of the impact the caller has to have
762
+ * earned. The two `access` sides are the caller's own four verbs and tell
763
+ * them nothing they could not ask `file_stat` for.
764
+ */
765
+ const copyImpact = async (branch, ctx, src, dest) => {
766
+ const [before, after, blocked] = await Promise.all([
767
+ accessAt(branch, ctx, src),
768
+ accessAfter(branch, ctx, src, dest, { sourceRemains: true }),
769
+ writeBlocked(branch, ctx, [dest]),
770
+ ]);
771
+ const access = { before, after };
772
+ const accessChanges = verbsDiffer(before, after);
773
+ if (blocked.length > 0) {
774
+ return {
775
+ src,
776
+ dest,
777
+ access,
778
+ accessChanges,
779
+ allowed: false,
780
+ reason: `You may not write "${dest}", so the copy cannot run.`,
781
+ dryRun: true,
782
+ copied: false,
783
+ };
784
+ }
785
+ const fs = await ctx.getFilesystem(branch);
786
+ const kind = await kindOf(fs, src);
787
+ if (kind === null)
788
+ throw notFound(src, 'Nothing to copy');
789
+ const srcFiles = kind === 'folder' ? (await filesUnder(fs, src)).files : [src];
790
+ const occupiedBy = await existingAt(await workspaceRoot(branch, ctx), dest);
791
+ const reason = occupiedBy !== null
792
+ ? entryExistsMessage(occupiedBy, dest)
793
+ : kind === 'folder'
794
+ ? folderCopyRefusal(src)
795
+ : undefined;
796
+ return {
797
+ src,
798
+ dest,
799
+ kind,
800
+ // The placeholder travels with its folder, but it is never content —
801
+ // counted as `move_file` counts it.
802
+ descendants: srcFiles.filter((f) => !isFolderPlaceholder(f)).length,
803
+ access,
804
+ accessChanges,
805
+ allowed: reason === undefined,
806
+ ...(reason !== undefined ? { reason } : {}),
807
+ dryRun: true,
808
+ copied: false,
809
+ };
810
+ };
674
811
  /**
675
812
  * The paths among `paths` the caller may NOT write, judged exactly as the
676
813
  * lock gate judges them (`WorkflowService.acquireLock`): on a protected
@@ -950,10 +1087,17 @@ changeGate) {
950
1087
  // tool the one content rule, and every tool a permission can refuse the
951
1088
  // proposal route — appended once here so no tool (especially the
952
1089
  // read-only ones a session hits first) can miss them.
1090
+ // Whether a call to this tool MUST name a branch, read off the tool's own
1091
+ // declaration rather than assumed of the family. Every tool mounted here
1092
+ // requires `branch` today; keying on the schema means a tool that declares
1093
+ // it optional (and resolves absence itself, as `list_tool_setup` does on its
1094
+ // own route) is not handed a refusal it never asked for.
1095
+ const requiresBranch = (spec.inputs.required ?? []).includes('branch');
953
1096
  const describe = () => (typeof spec.description === 'function' ? spec.description() : spec.description) +
954
1097
  (spec.proposable ? PROPOSAL_ROUTE_NOTE : '') +
955
1098
  (spec.fileTool === false ? '' : CONTENT_RULE) +
956
- kbConventionsNote(kb.layout);
1099
+ kbConventionsNote(kb.layout) +
1100
+ (spec.gated ? agentAccessGate.notes.gatedToolNote() : '');
957
1101
  const def = toolDef({
958
1102
  name: spec.name,
959
1103
  description: describe(),
@@ -965,16 +1109,33 @@ changeGate) {
965
1109
  registry.registerInternalTool(def);
966
1110
  if (!spec.internalOnly)
967
1111
  registry.registerExternalTool(def);
968
- // The catalog FOLLOWS the layout. The conventions reminder above names the
969
- // guide, and several descriptions name it again as a platform file, so the
970
- // save that completes first-run setup — which applies the names the admin
971
- // just chose, in that same request, without a restart — must be able to
972
- // move the text with them. Rewritten in place: the registry holds this
973
- // object, both surfaces hold the same one, and re-registering would be a
974
- // duplicate name.
975
- kb.onLayoutApplied(() => {
1112
+ /**
1113
+ * What the agent reads about this tool, rebuilt from whatever is in effect
1114
+ * NOW: the layout's names and the notes the deployment registered.
1115
+ *
1116
+ * The catalog FOLLOWS the layout. The conventions reminder above names the
1117
+ * guide, and several descriptions name it again as a platform file, so the
1118
+ * save that completes first-run setup — which applies the names the admin
1119
+ * just chose, in that same request, without a restart — must be able to
1120
+ * move the text with them. Rewritten in place: the registry holds this
1121
+ * object, both surfaces hold the same one, and re-registering would be a
1122
+ * duplicate name. The `sessionId` input is rewritten on the DEF rather
1123
+ * than on `SESSION_ID_INPUT`, because `toolDef` copies the schema it is
1124
+ * given.
1125
+ */
1126
+ const redescribe = () => {
976
1127
  def.description = describe();
977
- });
1128
+ const sessionId = sessionIdInputOf(def);
1129
+ if (sessionId)
1130
+ sessionId.description = agentAccessGate.notes.sessionIdDescription();
1131
+ };
1132
+ // Once for a note registered BEFORE the tools were mounted (the `sessionId`
1133
+ // input is copied by `toolDef`, so it carries the bare default until this
1134
+ // runs), and then on every later change: a note may be registered AFTER
1135
+ // the mount, from the tool-surface hook an overlay registers on.
1136
+ redescribe();
1137
+ kb.onLayoutApplied(redescribe);
1138
+ agentAccessGate.notes.onChange(redescribe);
978
1139
  // Internal-only tools (e.g. `execute_command`) keep their route mounted —
979
1140
  // our agent calls it over the same loopback — but gate it to internal-source
980
1141
  // callers so an external connection key can't invoke it by name.
@@ -991,6 +1152,17 @@ changeGate) {
991
1152
  // path, and exempting it there would be a workspace-relative path that
992
1153
  // never reached the repository — the whole bug, spelled with a prefix.
993
1154
  toolHandler(async (args, ctx) => {
1155
+ // FIRST, before the path work and before any handler: every tool
1156
+ // mounted here declares `branch` as a required, non-empty string, and
1157
+ // nothing enforced that, so a call that named none was carried down
1158
+ // until `workspaceIdForBranch` made a workspace directory out of the
1159
+ // missing value. Most of these tools would meet the same refusal one
1160
+ // layer down at `getFilesystem`, but not all of them do — `unzip`
1161
+ // hands `branch` straight to the workspace service by id — so the
1162
+ // check belongs on the mount every one of them shares rather than on
1163
+ // the resolver only some of them reach.
1164
+ if (requiresBranch && !spec.resolvesBranchItself)
1165
+ assertBranchProvided(args.branch);
994
1166
  // BEFORE the normaliser: see `assertToolPathsNotGitInternals`.
995
1167
  if (spec.fileTool !== false)
996
1168
  await assertToolPathsNotGitInternals(args, ctx);
@@ -1021,22 +1193,20 @@ changeGate) {
1021
1193
  }, { write: spec.write }));
1022
1194
  };
1023
1195
  // ── session bootstrap (external agents) ─────────────────────────────────
1024
- // Every read/write tool below scopes the ontology-session boundary off a
1025
- // `sessionId`. The in-process agent carries its thread id, but an external
1026
- // agent has no ambient run id and so cannot satisfy the gate until it has
1027
- // one. This mints that id up front (called ONCE); the MCP proxy then threads
1028
- // it onto every later gated call via its sessionId-output continuity
1196
+ // Every read/write tool below takes a `sessionId`: the conversation the
1197
+ // call belongs to, which is what a deployment's hooks scope their rule to.
1198
+ // The in-process agent carries its thread id, but an external agent has no
1199
+ // ambient run id, so this mints one up front (called ONCE); the MCP proxy
1200
+ // then threads it onto every later call via its sessionId-output continuity
1029
1201
  // convention. EXTERNAL-ONLY (not registered internal): the in-process agent
1030
1202
  // already supplies its session id and ignores any body value.
1031
1203
  //
1032
1204
  // WHAT the minted id is backed by is the `ISessionSink` port's business
1033
1205
  // (session-sink.ts). In the enterprise app it is a REAL chat-thread id, so
1034
- // the SAME id works end to end: KB reads scope the ontology boundary under
1035
- // it, AND `ask` accepts it (its sessionId IS a chat thread, resolved via
1036
- // getThread) — that unification is what stops a caller reading from one
1037
- // ontology and then having `ask` write into another. In a core-only
1038
- // deployment (no chat/ask) the default sink mints a bare id, which is all
1039
- // the ontology gate needs.
1206
+ // the SAME id works end to end: the file tools take it AND `ask` accepts it
1207
+ // (its sessionId IS a chat thread, resolved via getThread), so a run's reads
1208
+ // and its questions are one conversation rather than two. In a core-only
1209
+ // deployment (no chat/ask) the default sink mints a bare id.
1040
1210
  //
1041
1211
  // The description tells the caller that retrying is safe, and that is a
1042
1212
  // property of the sink rather than a promise this route makes on its own:
@@ -1048,7 +1218,7 @@ changeGate) {
1048
1218
  // transport hiccup on its first call as an unrecoverable start.
1049
1219
  const startSessionDef = toolDef({
1050
1220
  name: 'start_session',
1051
- description: '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 }`.',
1221
+ description: '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 }`.',
1052
1222
  path: '/api/agent/tools/start_session',
1053
1223
  inputs: { type: 'object', properties: {}, additionalProperties: false },
1054
1224
  outputs: {
@@ -1060,10 +1230,10 @@ changeGate) {
1060
1230
  });
1061
1231
  registry.registerExternalTool(startSessionDef);
1062
1232
  // Mint the session id via the sink and return it (see comment above: one id
1063
- // spans start_session -> reads -> ask, closing the ontology-pollution gap).
1233
+ // spans start_session -> reads -> ask).
1064
1234
  router.post('/agent/tools/start_session', toolAuth,
1065
1235
  // External-only: an internal token already carries its run's sessionId, so
1066
- // minting a new thread mid-run would reset the ontology boundary. Note
1236
+ // minting a new thread mid-run would split one run in two. Note
1067
1237
  // "external" includes the MCP proxy's `externalProxy` loopback tokens
1068
1238
  // (OAuth/JWT MCP sessions) — the verifier resolves those to
1069
1239
  // `source: 'external'`, and one such session may legitimately mint several
@@ -1075,8 +1245,8 @@ changeGate) {
1075
1245
  // ── reads ──────────────────────────────────────────────────────────────
1076
1246
  mount({
1077
1247
  name: 'read_file',
1078
- description: '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.' +
1079
- ONTOLOGY_BOUNDARY_NOTE,
1248
+ gated: true,
1249
+ description: '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.',
1080
1250
  inputs: {
1081
1251
  type: 'object',
1082
1252
  properties: {
@@ -1102,12 +1272,12 @@ changeGate) {
1102
1272
  if (spillStore.isSpillRef(p)) {
1103
1273
  return { path: p, content: await spillStore.read(p, offset, limit) };
1104
1274
  }
1105
- await recordOntologyRead(sessionOntologyGate, ctx, p);
1275
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, p);
1106
1276
  await assertCanRead(readGateFor(a.branch, ctx), p);
1107
1277
  const fs = await ctx.getFilesystem(a.branch);
1108
1278
  // Reading (extraction, image and binary handling included) happens AFTER
1109
- // the access gate and the ontology-read recording above — a document
1110
- // read is still a KB read. ONE registry dispatch picks the reader by
1279
+ // the access gate and the read hook above — a document read is still a
1280
+ // KB read. ONE registry dispatch picks the reader by
1111
1281
  // extension; everything below just maps its ReadResult onto the tool's
1112
1282
  // result shape.
1113
1283
  const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
@@ -1139,8 +1309,8 @@ changeGate) {
1139
1309
  });
1140
1310
  mount({
1141
1311
  name: 'list_files',
1142
- description: `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.` +
1143
- ONTOLOGY_BOUNDARY_NOTE,
1312
+ gated: true,
1313
+ description: `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.`,
1144
1314
  inputs: {
1145
1315
  type: 'object',
1146
1316
  properties: {
@@ -1170,7 +1340,7 @@ changeGate) {
1170
1340
  write: false,
1171
1341
  handler: async (a, ctx) => {
1172
1342
  const dir = a.path || '';
1173
- await recordOntologyRead(sessionOntologyGate, ctx, dir);
1343
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, dir);
1174
1344
  const fs = await ctx.getFilesystem(a.branch);
1175
1345
  const entries = withoutPlaceholder((await fs.readdir(dir || '.')));
1176
1346
  const filtered = await filterReadableEntries(readGateFor(a.branch, ctx), dir, entries);
@@ -1179,13 +1349,13 @@ changeGate) {
1179
1349
  });
1180
1350
  mount({
1181
1351
  name: 'file_stat',
1352
+ gated: true,
1182
1353
  description: () => '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`.' +
1183
1354
  ' Every entry also reports what you may DO with it. ' +
1184
1355
  `\`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. ` +
1185
1356
  '`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. ' +
1186
1357
  '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. ' +
1187
- 'Call this before a move or delete to see what it would touch.' +
1188
- ONTOLOGY_BOUNDARY_NOTE,
1358
+ 'Call this before a move or delete to see what it would touch.',
1189
1359
  inputs: {
1190
1360
  type: 'object',
1191
1361
  properties: {
@@ -1258,7 +1428,7 @@ changeGate) {
1258
1428
  handler: async (a, ctx) => {
1259
1429
  const p = a.path;
1260
1430
  const branch = a.branch;
1261
- await recordOntologyRead(sessionOntologyGate, ctx, p);
1431
+ await notifyAgentRead(agentAccessGate, ctx, branch, p);
1262
1432
  await assertCanRead(readGateFor(branch, ctx), p);
1263
1433
  // Nothing there is a 404, and the placeholder — never content — gets
1264
1434
  // exactly that answer: the one every file tool gives (see not-found.ts).
@@ -1360,8 +1530,8 @@ changeGate) {
1360
1530
  });
1361
1531
  mount({
1362
1532
  name: 'grep',
1363
- description: '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).' +
1364
- ONTOLOGY_BOUNDARY_NOTE,
1533
+ gated: true,
1534
+ description: '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).',
1365
1535
  inputs: {
1366
1536
  type: 'object',
1367
1537
  properties: {
@@ -1412,11 +1582,10 @@ changeGate) {
1412
1582
  // (an empty path is the handler's to explain), and here it would
1413
1583
  // otherwise name the workspace directory by another spelling.
1414
1584
  const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
1415
- // The search root itself is checked here (fail-closed for an agent grep on
1416
- // a named subtree with no sessionId); each file the walk actually opens is
1417
- // recorded per-file below, so a root-level grep that reaches into multiple
1418
- // ontologies still records each one (and can poison later writes).
1419
- await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
1585
+ // The search root itself goes to the read hook here; each file the walk
1586
+ // actually opens goes to it per-file below, so a hook sees every path a
1587
+ // grep reached rather than only the root it started from.
1588
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, searchRoot);
1420
1589
  const fs = await ctx.getFilesystem(a.branch);
1421
1590
  const gate = readGateFor(a.branch, ctx);
1422
1591
  const out = [];
@@ -1434,7 +1603,7 @@ changeGate) {
1434
1603
  /** Why a single-file search found nothing, when "no matches" would be a lie. */
1435
1604
  let fileNote;
1436
1605
  if (kind === 'directory') {
1437
- await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) => recordOntologyRead(sessionOntologyGate, ctx, p), docs);
1606
+ await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) => notifyAgentRead(agentAccessGate, ctx, a.branch, p), docs);
1438
1607
  }
1439
1608
  else {
1440
1609
  // Not a directory: the permission verdict comes BEFORE every other
@@ -1473,11 +1642,12 @@ changeGate) {
1473
1642
  // ── writes (through the lock/commit pipeline) ───────────────────────────
1474
1643
  mount({
1475
1644
  name: 'write_file',
1645
+ gated: true,
1476
1646
  description: 'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
1477
1647
  '`created`, `replaced` or `updated`.' +
1478
1648
  WRITE_MODE_NOTE +
1479
1649
  IMAGE_CONVENTION_NOTE +
1480
- ONTOLOGY_BOUNDARY_NOTE,
1650
+ ESCAPE_SEQUENCE_NOTE,
1481
1651
  inputs: {
1482
1652
  type: 'object',
1483
1653
  properties: {
@@ -1513,7 +1683,7 @@ changeGate) {
1513
1683
  // (today only `watchlist_check`, to `.html`). Unrestricted sessions pass straight
1514
1684
  // through (see `assertPathWritable`), so it does not limit other agents.
1515
1685
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
1516
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path);
1686
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
1517
1687
  const mode = modeOf(a);
1518
1688
  const fs = await ctx.getFilesystem(a.branch);
1519
1689
  await assertNotBinaryOverwrite(readers, a.path, fs);
@@ -1546,17 +1716,18 @@ changeGate) {
1546
1716
  });
1547
1717
  mount({
1548
1718
  name: 'write_files',
1719
+ gated: true,
1549
1720
  description: 'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
1550
1721
  'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`, and the files it ' +
1551
1722
  'writes are committed + pushed together as you. Prefer this over many write_file ' +
1552
- 'calls. All files must be in the SAME ontology (the boundary below applies to the batch). Text files only. ' +
1723
+ 'calls. Text files only. ' +
1553
1724
  'Returns `{ count, files }`: one entry per REQUESTED path, in the order you gave them, each `{ path, outcome }` — ' +
1554
1725
  '`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
1555
1726
  'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
1556
1727
  'does not stop the others; read `files` to see what landed.' +
1557
1728
  WRITE_MODE_NOTE +
1558
1729
  IMAGE_CONVENTION_NOTE +
1559
- ONTOLOGY_BOUNDARY_NOTE,
1730
+ ESCAPE_SEQUENCE_NOTE,
1560
1731
  inputs: {
1561
1732
  type: 'object',
1562
1733
  properties: {
@@ -1610,15 +1781,14 @@ changeGate) {
1610
1781
  if (files.length === 0)
1611
1782
  return { count: 0, files: [] };
1612
1783
  const mode = modeOf(a);
1613
- // The POLICY gates still judge the whole batch: a restricted run or a
1614
- // cross-ontology batch is a call that should not have been made at all,
1615
- // not a per-path outcome, and the ontology gate must see every path
1616
- // before anything lands. What a single FILE is (not text) or what its
1617
- // path already holds (the mode) is decided per path, below.
1784
+ // The POLICY gate still judges the whole batch: a restricted run is a
1785
+ // call that should not have been made at all, not a per-path outcome.
1786
+ // The write hook is asked PER PATH, below, so a path it refuses is that
1787
+ // path's outcome and the rest of the batch still lands. What a single
1788
+ // FILE is (not text) or what its path already holds (the mode) is
1789
+ // decided per path too.
1618
1790
  for (const f of files)
1619
1791
  writePolicy.assertPathWritable(ctx.sessionId, f.path);
1620
- for (const f of files)
1621
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
1622
1792
  const fs = await ctx.getFilesystem(a.branch);
1623
1793
  // `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
1624
1794
  // batch as one commit. Structural cast avoids a workflow-internal import.
@@ -1639,6 +1809,19 @@ changeGate) {
1639
1809
  for (const f of files) {
1640
1810
  const entry = { path: f.path };
1641
1811
  outcomes.push(entry);
1812
+ try {
1813
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, f.path);
1814
+ }
1815
+ catch (err) {
1816
+ // A DELIBERATE refusal by the deployment's write hook is this path's
1817
+ // outcome and no more: its message is what the caller is meant to
1818
+ // read, and one refused path must not take the others down. Anything
1819
+ // else the hook throws is not a verdict — it is the gate itself
1820
+ // failing — so `refuse` rethrows it and the whole batch fails loudly,
1821
+ // exactly as it does in `write_file`.
1822
+ refuse(entry, err);
1823
+ continue;
1824
+ }
1642
1825
  try {
1643
1826
  assertNotDocumentEdit(readers, f.path);
1644
1827
  await assertNotBinaryOverwrite(readers, f.path, fs);
@@ -1706,8 +1889,9 @@ changeGate) {
1706
1889
  });
1707
1890
  mount({
1708
1891
  name: 'edit_file',
1892
+ gated: true,
1709
1893
  description: 'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
1710
- ONTOLOGY_BOUNDARY_NOTE,
1894
+ ESCAPE_SEQUENCE_NOTE,
1711
1895
  inputs: {
1712
1896
  type: 'object',
1713
1897
  properties: {
@@ -1731,7 +1915,7 @@ changeGate) {
1731
1915
  handler: async (a, ctx) => {
1732
1916
  assertNotDocumentEdit(readers, a.path);
1733
1917
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
1734
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path);
1918
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
1735
1919
  const fs = await ctx.getFilesystem(a.branch);
1736
1920
  const path = a.path;
1737
1921
  const oldStr = a.old_string;
@@ -1755,9 +1939,9 @@ changeGate) {
1755
1939
  });
1756
1940
  mount({
1757
1941
  name: 'delete_file',
1942
+ gated: true,
1758
1943
  description: () => '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`. ' +
1759
- `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.` +
1760
- ONTOLOGY_BOUNDARY_NOTE,
1944
+ `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.`,
1761
1945
  inputs: {
1762
1946
  type: 'object',
1763
1947
  properties: {
@@ -1776,14 +1960,14 @@ changeGate) {
1776
1960
  write: true,
1777
1961
  proposable: true,
1778
1962
  handler: async (a, ctx) => {
1779
- // A delete propagates no cross-ontology information (it removes a node, it
1780
- // doesn't carry bytes from elsewhere), so it is NOT ontology-write-gated — it
1781
- // only records the ontology it touched, like a read. The extension policy
1782
- // DOES apply though: a dashboard-only run must not delete graph `.md` nodes.
1963
+ // A delete carries no bytes from anywhere else — it removes a node — so
1964
+ // it goes to the READ hook, like a read, not the write hook. The
1965
+ // extension policy DOES apply though: a dashboard-only run must not
1966
+ // delete graph `.md` nodes.
1783
1967
  const path = a.path;
1784
1968
  const branch = a.branch;
1785
1969
  writePolicy.assertPathWritable(ctx.sessionId, path);
1786
- await recordOntologyRead(sessionOntologyGate, ctx, path);
1970
+ await notifyAgentRead(agentAccessGate, ctx, branch, path);
1787
1971
  const fs = await ctx.getFilesystem(branch);
1788
1972
  assertPlainPath(path);
1789
1973
  const root = await workspaceRoot(branch, ctx);
@@ -1809,12 +1993,12 @@ changeGate) {
1809
1993
  });
1810
1994
  mount({
1811
1995
  name: 'delete_folder',
1996
+ gated: true,
1812
1997
  description: '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. ' +
1813
1998
  '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. ' +
1814
1999
  '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. ' +
1815
2000
  '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). ' +
1816
- '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.' +
1817
- ONTOLOGY_BOUNDARY_NOTE,
2001
+ '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.',
1818
2002
  inputs: {
1819
2003
  type: 'object',
1820
2004
  properties: {
@@ -1852,7 +2036,7 @@ changeGate) {
1852
2036
  // The normaliser has already placed the path inside the repository; this
1853
2037
  // is the check that it really is in there before a folder is walked.
1854
2038
  assertInsideRepo(path, kbDirName);
1855
- await recordOntologyRead(sessionOntologyGate, ctx, path);
2039
+ await notifyAgentRead(agentAccessGate, ctx, branch, path);
1856
2040
  const fs = await ctx.getFilesystem(branch);
1857
2041
  const kind = await kindOf(fs, path);
1858
2042
  if (kind === null)
@@ -1946,7 +2130,8 @@ changeGate) {
1946
2130
  });
1947
2131
  mount({
1948
2132
  name: 'mkdir',
1949
- description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.' + ONTOLOGY_BOUNDARY_NOTE,
2133
+ gated: true,
2134
+ description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.',
1950
2135
  inputs: {
1951
2136
  type: 'object',
1952
2137
  properties: {
@@ -1966,18 +2151,18 @@ changeGate) {
1966
2151
  proposable: true,
1967
2152
  handler: async (a, ctx) => {
1968
2153
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
1969
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path);
2154
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
1970
2155
  await (await ctx.getFilesystem(a.branch)).mkdir(a.path, { recursive: true });
1971
2156
  return { path: a.path, created: true };
1972
2157
  },
1973
2158
  });
1974
2159
  mount({
1975
2160
  name: 'move_file',
2161
+ gated: true,
1976
2162
  description: () => '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. ' +
1977
2163
  `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. ` +
1978
- '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. ' +
1979
- '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.' +
1980
- ONTOLOGY_BOUNDARY_NOTE,
2164
+ '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. ' +
2165
+ '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.',
1981
2166
  inputs: {
1982
2167
  type: 'object',
1983
2168
  properties: {
@@ -1998,8 +2183,8 @@ changeGate) {
1998
2183
  dest: str('Destination path (echoes the input).'),
1999
2184
  kind: str('`file` or `folder`.'),
2000
2185
  descendants: int('Files that move: 1 for a file, the file count under a folder.'),
2001
- access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and destination (`after`).' },
2002
- accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between source and destination.' },
2186
+ access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the move has landed (`after`).' },
2187
+ accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after`.' },
2003
2188
  allowed: { type: 'boolean', description: 'Whether the move may run.' },
2004
2189
  reason: str('Why it may not, when `allowed` is false.'),
2005
2190
  dryRun: { type: 'boolean', description: 'True on a dry run.' },
@@ -2017,12 +2202,12 @@ changeGate) {
2017
2202
  const src = a.src.replace(/\/+$/, '');
2018
2203
  const dest = a.dest.replace(/\/+$/, '');
2019
2204
  const branch = a.branch;
2020
- // A move CARRIES the source content into the destination — a genuine
2021
- // cross-ontology flow if the two differ — so BOTH endpoints are write-gated
2022
- // (unlike a plain delete, which moves no content). Check both BEFORE
2023
- // touching disk so a blocked endpoint can't leave the source already deleted.
2024
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, src);
2025
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, dest);
2205
+ // A move CARRIES the source content into the destination, so BOTH ends
2206
+ // go to the write hook (unlike a plain delete, which moves no content).
2207
+ // Ask about both BEFORE touching disk, so a refused end can't leave the
2208
+ // source already deleted.
2209
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, src);
2210
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, dest);
2026
2211
  const fs = await ctx.getFilesystem(branch);
2027
2212
  const root = await workspaceRoot(branch, ctx);
2028
2213
  // Before the kind check, which stats THROUGH a link: a dangling link at
@@ -2039,8 +2224,13 @@ changeGate) {
2039
2224
  writePolicy.assertPathWritable(ctx.sessionId, f);
2040
2225
  writePolicy.assertPathWritable(ctx.sessionId, dest + f.slice(src.length));
2041
2226
  }
2042
- const [before, after] = await Promise.all([accessAt(branch, ctx, src), accessAt(branch, ctx, dest)]);
2043
- const accessChanges = Object.keys(before).some((v) => before[v] !== after[v]);
2227
+ // `after` is the destination as it WILL be — with the `access.md` files
2228
+ // under `src` counted where they land. See `accessAfter`.
2229
+ const [before, after] = await Promise.all([
2230
+ accessAt(branch, ctx, src),
2231
+ accessAfter(branch, ctx, src, dest),
2232
+ ]);
2233
+ const accessChanges = verbsDiffer(before, after);
2044
2234
  // The placeholder moves with its folder, but it is never content.
2045
2235
  const descendants = srcFiles.filter((f) => !isFolderPlaceholder(f)).length;
2046
2236
  // Neither end may be the platform's own: a move neither takes a platform
@@ -2135,14 +2325,17 @@ changeGate) {
2135
2325
  });
2136
2326
  mount({
2137
2327
  name: 'copy_file',
2138
- description: '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.'
2139
- + ONTOLOGY_BOUNDARY_NOTE,
2328
+ gated: true,
2329
+ description: '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. ' +
2330
+ '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. ' +
2331
+ '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.',
2140
2332
  inputs: {
2141
2333
  type: 'object',
2142
2334
  properties: {
2143
2335
  branch: BRANCH_INPUT,
2144
2336
  src: wsPath(kbDirName, 'Source path'),
2145
2337
  dest: wsPath(kbDirName, 'Destination path — must not exist yet'),
2338
+ dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
2146
2339
  sessionId: SESSION_ID_INPUT,
2147
2340
  },
2148
2341
  required: ['branch', 'src', 'dest'],
@@ -2150,20 +2343,30 @@ changeGate) {
2150
2343
  },
2151
2344
  outputs: {
2152
2345
  type: 'object',
2153
- properties: { src: str('Source path (echoes the input).'), dest: str('Destination path (echoes the input).'), copied: { type: 'boolean', description: 'Always true on success.' } },
2346
+ properties: {
2347
+ src: str('Source path (echoes the input).'),
2348
+ dest: str('Destination path (echoes the input).'),
2349
+ kind: str('`file` or `folder` (dry run only; absent when `allowed` is false because you may not write the destination).'),
2350
+ 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).'),
2351
+ 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.' },
2352
+ accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after` (dry run only).' },
2353
+ allowed: { type: 'boolean', description: 'Whether the copy may run (dry run only).' },
2354
+ reason: str('Why it may not, when `allowed` is false.'),
2355
+ dryRun: { type: 'boolean', description: 'True on a dry run.' },
2356
+ copied: { type: 'boolean', description: 'True once the copy landed; false on a dry run.' },
2357
+ },
2154
2358
  required: ['src', 'dest', 'copied'],
2155
2359
  },
2156
2360
  write: true,
2157
2361
  proposable: true,
2158
2362
  handler: async (a, ctx) => {
2159
- // A copy CARRIES the source content into the destination — a genuine
2160
- // cross-ontology flow if the two differ — so BOTH endpoints are write-gated.
2161
- // Check both before touching disk.
2363
+ // A copy CARRIES the source content into the destination, so BOTH ends
2364
+ // go to the write hook. Ask about both before touching disk.
2162
2365
  writePolicy.assertPathWritable(ctx.sessionId, a.src);
2163
2366
  writePolicy.assertPathWritable(ctx.sessionId, a.dest);
2164
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src);
2165
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest);
2166
2367
  const branch = a.branch;
2368
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.src);
2369
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.dest);
2167
2370
  const src = a.src;
2168
2371
  const dest = a.dest;
2169
2372
  // A copy lands bytes at a name of its own, so it is refused by the same
@@ -2175,6 +2378,8 @@ changeGate) {
2175
2378
  // own containment check: a path with a `..` segment must not reach
2176
2379
  // `lstat` outside the workspace, even to be told a name is taken.
2177
2380
  assertPlainPath(dest);
2381
+ if (a.dryRun === true)
2382
+ return copyImpact(branch, ctx, src, dest);
2178
2383
  // The write verdict comes FIRST, for the reason `move_file` gives at
2179
2384
  // length: "already exists" is a fact about the destination folder, and a
2180
2385
  // caller who may not write there must not be told it. The lock gate
@@ -2214,6 +2419,12 @@ changeGate) {
2214
2419
  await asEntryExists(() => fs.copyFile(src, dest));
2215
2420
  }
2216
2421
  catch (err) {
2422
+ // The filesystem's own "that is a directory" becomes the sentence the
2423
+ // dry run predicts, instead of escaping as a 500 carrying the
2424
+ // server's absolute path.
2425
+ if (err?.name === 'IsDirectoryError') {
2426
+ throw new ToolError(folderCopyRefusal(src), 400);
2427
+ }
2217
2428
  const missing = isAbsence(err) || err.name === 'FileNotFoundError';
2218
2429
  if (missing) {
2219
2430
  throw (await kindOf(fs, src)) === null
@@ -2227,8 +2438,8 @@ changeGate) {
2227
2438
  });
2228
2439
  mount({
2229
2440
  name: 'unzip',
2230
- description: 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.' +
2231
- ONTOLOGY_BOUNDARY_NOTE,
2441
+ gated: true,
2442
+ description: 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.',
2232
2443
  inputs: {
2233
2444
  type: 'object',
2234
2445
  properties: {
@@ -2260,19 +2471,19 @@ changeGate) {
2260
2471
  write: true,
2261
2472
  handler: async (a, ctx) => {
2262
2473
  const zipPath = a.path;
2263
- // Reading the source archive pins/records the source ontology, so a session
2264
- // can't unzip from ontology A into ontology B without the A read counting.
2265
- await recordOntologyRead(sessionOntologyGate, ctx, zipPath);
2474
+ // Opening the archive is a read of the archive, so the read hook hears
2475
+ // about it before a single entry is extracted out of it.
2476
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, zipPath);
2266
2477
  // A .zip that is not there is a missing PATH, not an unreadable archive:
2267
2478
  // the service now says so (PathNotFoundError) and the helper turns it
2268
2479
  // into the same 404 every other file tool answers. Only that declared
2269
2480
  // answer maps — a failure part-way through an extraction is not the
2270
2481
  // archive going missing.
2271
2482
  return orDeclaredNotFound(() => ctx.workspaceService.unzipFile(workspaceIdForBranch(a.branch), zipPath, typeof a.destination === 'string' ? a.destination : undefined,
2272
- // Each extracted file is a write: a cross-ontology or write-blocked entry
2273
- // is skipped (not extracted), so an archive can't bypass the boundary — the
2274
- // extension policy applies per entry too, so a restricted run can't unzip a
2275
- // `.md` into the graph.
2483
+ // Each extracted file is a write of its own: an entry the write
2484
+ // hook refuses is skipped (not extracted), so an archive can't be
2485
+ // a way around it — the extension policy applies per entry too, so
2486
+ // a restricted run can't unzip a `.md` into the graph.
2276
2487
  (wsRelPath) => {
2277
2488
  // An entry that would land beside the repository is skipped with the
2278
2489
  // corrected-path reason, like any other refused entry.
@@ -2283,17 +2494,20 @@ changeGate) {
2283
2494
  throw new ToolError('roles.yaml is never extracted from an archive — change it with edit_file or write_file, where the change is checked.', 422);
2284
2495
  }
2285
2496
  writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
2286
- return assertOntologyWriteAllowed(sessionOntologyGate, ctx, wsRelPath);
2497
+ return assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, wsRelPath);
2287
2498
  }), 'Nothing to extract');
2288
2499
  },
2289
2500
  });
2290
2501
  // ── shell (internal-only) ───────────────────────────────────────────────
2291
2502
  mount({
2292
2503
  name: 'execute_command',
2293
- description: 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.' +
2294
- ONTOLOGY_BOUNDARY_NOTE,
2504
+ gated: true,
2505
+ description: 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.',
2295
2506
  internalOnly: true,
2296
2507
  fileTool: false,
2508
+ // The one tool the mount's branch check skips: the handler below resolves an
2509
+ // omitted `branch` to the internal caller's focused branch before refusing.
2510
+ resolvesBranchItself: true,
2297
2511
  inputs: {
2298
2512
  type: 'object',
2299
2513
  properties: {
@@ -2347,7 +2561,12 @@ changeGate) {
2347
2561
  const raw = a.branch;
2348
2562
  const branch = raw === undefined ? ctx.focusedBranch : raw;
2349
2563
  if (typeof branch !== 'string' || branch.length === 0) {
2350
- throw new ToolError('execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.', 400);
2564
+ throw new ToolError('execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.', 400,
2565
+ // The same discriminator every other KB tool answers a branch-less
2566
+ // call with, so a client switches on one kind across the surface.
2567
+ // The MESSAGE stays this tool's own: it can name the focused-branch
2568
+ // fallback that only applies here.
2569
+ { kind: 'branch-required' });
2351
2570
  }
2352
2571
  // A stringified absent value. Both are syntactically valid git branch names,
2353
2572
  // so `assertValidBranchName` below happily accepts them — and accepting one
@@ -2356,7 +2575,7 @@ changeGate) {
2356
2575
  // and 500s. Reject them by name, ahead of the shape check.
2357
2576
  if (branch === 'undefined' || branch === 'null') {
2358
2577
  throw new ToolError(`execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
2359
- 'Pass the real branch (draft) whose workspace to run the command in.', 400);
2578
+ 'Pass the real branch (draft) whose workspace to run the command in.', 400, { kind: 'branch-required' });
2360
2579
  }
2361
2580
  // Then the SHAPE, via the one canonical validator every other branch path
2362
2581
  // uses — no hand-maintained list of suspicious literals, which would both
@@ -2371,11 +2590,12 @@ changeGate) {
2371
2590
  throw new ToolError(`execute_command got an invalid \`branch\`: ${err.message} — ` +
2372
2591
  'pass the exact branch (draft) whose workspace to run the command in.', 400);
2373
2592
  }
2374
- // Shell is a write path with no single target path to check, so enforce the
2375
- // boundary at the session level: refuse once the run is already write-blocked,
2376
- // or when the run is restricted to a file type (shell could write anything).
2593
+ // Shell is a write path with no single target path to check, so the
2594
+ // write hook is asked once for the call itself, with no path — and the
2595
+ // run must not be restricted to a file type either, since shell could
2596
+ // write anything.
2377
2597
  writePolicy.assertUnrestricted(ctx.sessionId);
2378
- await assertShellAllowedWithinOntology(sessionOntologyGate, ctx);
2598
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
2379
2599
  // Canonical per-branch bootstrap entry point — it owns the workspace-id
2380
2600
  // encoding and the single-flight clone, so the shell never derives a
2381
2601
  // workspace path by hand.