@bevel-software/platform-core-backend 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (466) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +46 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +12 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +137 -24
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +46 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +99 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +16 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +28 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +6 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +12 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +17 -16
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  34. package/dist/modules/access/directory-sync-bot.js +7 -3
  35. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  36. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  37. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  38. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  39. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  40. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  41. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  42. package/dist/modules/agent-instructions/compose.js +39 -6
  43. package/dist/modules/agent-instructions/compose.js.map +1 -1
  44. package/dist/modules/agent-instructions/index.d.ts +2 -1
  45. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  46. package/dist/modules/agent-instructions/index.js +2 -1
  47. package/dist/modules/agent-instructions/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  49. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  50. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  51. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  52. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  53. package/dist/modules/audit/agent-audit.service.js +4 -2
  54. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  55. package/dist/modules/auth/account-admission.d.ts +58 -7
  56. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  57. package/dist/modules/auth/account-admission.js +44 -1
  58. package/dist/modules/auth/account-admission.js.map +1 -1
  59. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  60. package/dist/modules/auth/account-erasure.service.js +57 -16
  61. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  62. package/dist/modules/auth/account.routes.d.ts +1 -1
  63. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  64. package/dist/modules/auth/account.routes.js +61 -5
  65. package/dist/modules/auth/account.routes.js.map +1 -1
  66. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  67. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  68. package/dist/modules/auth/auth.middleware.js +18 -7
  69. package/dist/modules/auth/auth.middleware.js.map +1 -1
  70. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  71. package/dist/modules/auth/auth.routes.js +8 -0
  72. package/dist/modules/auth/auth.routes.js.map +1 -1
  73. package/dist/modules/auth/auth.service.d.ts +70 -2
  74. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  75. package/dist/modules/auth/auth.service.js +197 -18
  76. package/dist/modules/auth/auth.service.js.map +1 -1
  77. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  78. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  79. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  80. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  81. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  82. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  83. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  84. package/dist/modules/database/connection.d.ts +16 -0
  85. package/dist/modules/database/connection.d.ts.map +1 -1
  86. package/dist/modules/database/connection.js +117 -0
  87. package/dist/modules/database/connection.js.map +1 -1
  88. package/dist/modules/database/core-schema.d.ts +337 -120
  89. package/dist/modules/database/core-schema.d.ts.map +1 -1
  90. package/dist/modules/database/core-schema.js +116 -58
  91. package/dist/modules/database/core-schema.js.map +1 -1
  92. package/dist/modules/database/migrate.d.ts +8 -0
  93. package/dist/modules/database/migrate.d.ts.map +1 -1
  94. package/dist/modules/database/migrate.js +271 -1
  95. package/dist/modules/database/migrate.js.map +1 -1
  96. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  97. package/dist/modules/kb-fs/branch-name.js +12 -2
  98. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  99. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  100. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  101. package/dist/modules/kb-fs/repo-path.js +79 -0
  102. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  104. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  105. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  106. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  108. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js +1 -1
  110. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  112. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  113. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  114. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  115. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  116. package/dist/modules/mcp/mcp.service.js +38 -8
  117. package/dist/modules/mcp/mcp.service.js.map +1 -1
  118. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  119. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  120. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  121. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-request-records.store.js +7 -4
  123. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  124. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  125. package/dist/modules/settings/deployment-settings.service.js +8 -3
  126. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  127. package/dist/modules/settings/setup.routes.d.ts +52 -1
  128. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  129. package/dist/modules/settings/setup.routes.js +207 -19
  130. package/dist/modules/settings/setup.routes.js.map +1 -1
  131. package/dist/modules/skills/skills.contract.d.ts +90 -18
  132. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  133. package/dist/modules/skills/skills.contract.js +4 -1
  134. package/dist/modules/skills/skills.contract.js.map +1 -1
  135. package/dist/modules/skills/skills.service.d.ts +98 -9
  136. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  137. package/dist/modules/skills/skills.service.js +239 -37
  138. package/dist/modules/skills/skills.service.js.map +1 -1
  139. package/dist/modules/skills/skills.tools.d.ts +7 -0
  140. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  141. package/dist/modules/skills/skills.tools.js +71 -7
  142. package/dist/modules/skills/skills.tools.js.map +1 -1
  143. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  144. package/dist/modules/tool-auth/external-api-key.service.js +12 -4
  145. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  146. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  147. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  148. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  149. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  150. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  151. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  152. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  153. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  154. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  155. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  156. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  157. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  158. package/dist/modules/tool-helpers/validate-token.js +1 -1
  159. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  160. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  161. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  162. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  163. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  164. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  165. package/dist/modules/tool-registry/description-length.js +108 -0
  166. package/dist/modules/tool-registry/description-length.js.map +1 -0
  167. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  168. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  169. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  170. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  171. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  172. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  173. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  174. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  175. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  176. package/dist/modules/workflow/file-lock.service.js +30 -0
  177. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  178. package/dist/modules/workflow/git/git.service.d.ts +94 -0
  179. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  180. package/dist/modules/workflow/git/git.service.js +193 -3
  181. package/dist/modules/workflow/git/git.service.js.map +1 -1
  182. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  183. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  184. package/dist/modules/workflow/pending-commits.service.js +60 -1
  185. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  186. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  187. package/dist/modules/workflow/recovery-bot.js +7 -3
  188. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  189. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  190. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  191. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  192. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  193. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  194. package/dist/modules/workflow/workflow-hooks.js +16 -1
  195. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  196. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  197. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  198. package/dist/modules/workflow/workflow.service.js +115 -5
  199. package/dist/modules/workflow/workflow.service.js.map +1 -1
  200. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  201. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  202. package/dist/modules/workspace/agent-access.gate.js +123 -0
  203. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  204. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  205. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  206. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  207. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  208. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  209. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  210. package/dist/modules/workspace/agent-upload.store.js +553 -0
  211. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  212. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  213. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  214. package/dist/modules/workspace/routine-write-policy.js +5 -6
  215. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  216. package/dist/modules/workspace/session-sink.d.ts +5 -5
  217. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  218. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  219. package/dist/modules/workspace/set-aside-clone.js +92 -0
  220. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  221. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  222. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  223. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  224. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  225. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  226. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  227. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  228. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  229. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  231. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  232. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  233. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  234. package/dist/modules/workspace/upload-limits.js +13 -0
  235. package/dist/modules/workspace/upload-limits.js.map +1 -0
  236. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  237. package/dist/modules/workspace/workspace.routes.js +93 -4
  238. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  239. package/dist/modules/workspace/workspace.service.d.ts +128 -5
  240. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  241. package/dist/modules/workspace/workspace.service.js +316 -58
  242. package/dist/modules/workspace/workspace.service.js.map +1 -1
  243. package/dist/modules/workspace/workspace.tools.d.ts +13 -11
  244. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  245. package/dist/modules/workspace/workspace.tools.js +791 -198
  246. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  247. package/dist/modules/workspace/write-denial.d.ts +0 -6
  248. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  249. package/dist/modules/workspace/write-denial.js +0 -6
  250. package/dist/modules/workspace/write-denial.js.map +1 -1
  251. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  252. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  253. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  254. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  255. package/dist/modules/write-access/write-access.d.ts +60 -0
  256. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  257. package/dist/modules/write-access/write-access.js +129 -0
  258. package/dist/modules/write-access/write-access.js.map +1 -0
  259. package/dist/shared/column-crypto.d.ts +194 -0
  260. package/dist/shared/column-crypto.d.ts.map +1 -0
  261. package/dist/shared/column-crypto.js +144 -0
  262. package/dist/shared/column-crypto.js.map +1 -0
  263. package/dist/shared/domain-errors.d.ts +25 -0
  264. package/dist/shared/domain-errors.d.ts.map +1 -1
  265. package/dist/shared/domain-errors.js +28 -0
  266. package/dist/shared/domain-errors.js.map +1 -1
  267. package/dist/shared/git.contract.d.ts +20 -0
  268. package/dist/shared/git.contract.d.ts.map +1 -1
  269. package/dist/shared/git.contract.js +26 -0
  270. package/dist/shared/git.contract.js.map +1 -1
  271. package/dist/shared/token-crypto.d.ts.map +1 -1
  272. package/dist/shared/token-crypto.js +25 -1
  273. package/dist/shared/token-crypto.js.map +1 -1
  274. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  275. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  276. package/dist/tenancy/static-tenant-source.js +1 -2
  277. package/dist/tenancy/static-tenant-source.js.map +1 -1
  278. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  279. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  280. package/dist/tenancy/tenant-secrets.js +4 -0
  281. package/dist/tenancy/tenant-secrets.js.map +1 -1
  282. package/kb-template/AGENTS.md +2 -0
  283. package/migrations/0014_change_request_closed_reason.sql +1 -0
  284. package/migrations/0015_account_deactivation.sql +3 -0
  285. package/migrations/0016_pii_encryption.sql +20 -0
  286. package/migrations/meta/0014_snapshot.json +2265 -0
  287. package/migrations/meta/0015_snapshot.json +2271 -0
  288. package/migrations/meta/0016_snapshot.json +2327 -0
  289. package/migrations/meta/_journal.json +21 -0
  290. package/package.json +3 -3
  291. package/src/__tests__/kb-layout-config.test.ts +0 -2
  292. package/src/__tests__/retired-settings.test.ts +96 -0
  293. package/src/core/__tests__/gated-boot.test.ts +132 -0
  294. package/src/core/__tests__/lifecycle.test.ts +241 -5
  295. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  296. package/src/core/core-ports.ts +11 -1
  297. package/src/core/create-core-server.ts +52 -16
  298. package/src/core/create-core-services.ts +156 -24
  299. package/src/core/lifecycle.ts +117 -13
  300. package/src/core-config.ts +31 -14
  301. package/src/index.ts +30 -1
  302. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  303. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  304. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  305. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  306. package/src/modules/access/access-control.interface.ts +43 -12
  307. package/src/modules/access/access-control.service.ts +234 -36
  308. package/src/modules/access/access.routes.ts +17 -16
  309. package/src/modules/access/directory-sync-bot.ts +7 -3
  310. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
  311. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  312. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  313. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  314. package/src/modules/agent-instructions/compose.ts +50 -7
  315. package/src/modules/agent-instructions/index.ts +12 -0
  316. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  317. package/src/modules/audit/agent-audit.service.ts +4 -2
  318. package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
  319. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  320. package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
  321. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  322. package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
  323. package/src/modules/auth/account-admission.ts +78 -9
  324. package/src/modules/auth/account-erasure.service.ts +69 -18
  325. package/src/modules/auth/account.routes.ts +63 -7
  326. package/src/modules/auth/auth.middleware.ts +19 -8
  327. package/src/modules/auth/auth.routes.ts +8 -0
  328. package/src/modules/auth/auth.service.ts +206 -23
  329. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  330. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  331. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  332. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  333. package/src/modules/database/__tests__/connection.test.ts +12 -0
  334. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  335. package/src/modules/database/connection.ts +117 -0
  336. package/src/modules/database/core-schema.ts +116 -58
  337. package/src/modules/database/migrate.ts +353 -1
  338. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  339. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  340. package/src/modules/kb-fs/branch-name.ts +14 -1
  341. package/src/modules/kb-fs/repo-path.ts +85 -0
  342. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  343. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  344. package/src/modules/kb-sync/sync-auth.ts +2 -2
  345. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  346. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  347. package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
  348. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  349. package/src/modules/mcp/mcp.service.ts +46 -7
  350. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  351. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  352. package/src/modules/plugins/join-request-records.store.ts +7 -4
  353. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  354. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  355. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  356. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  357. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  358. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  359. package/src/modules/settings/deployment-settings.service.ts +8 -3
  360. package/src/modules/settings/setup.routes.ts +263 -19
  361. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  362. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  363. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  364. package/src/modules/skills/skills.contract.ts +91 -18
  365. package/src/modules/skills/skills.service.ts +278 -41
  366. package/src/modules/skills/skills.tools.ts +80 -8
  367. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  368. package/src/modules/tool-auth/external-api-key.service.ts +12 -4
  369. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  370. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  371. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  372. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  373. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  374. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  375. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  376. package/src/modules/tool-helpers/validate-token.ts +1 -1
  377. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  378. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  379. package/src/modules/tool-registry/description-length.ts +111 -0
  380. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  381. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  382. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  383. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  384. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  385. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  386. package/src/modules/workflow/file-lock.service.ts +31 -0
  387. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  388. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  389. package/src/modules/workflow/git/git.service.ts +215 -1
  390. package/src/modules/workflow/pending-commits.service.ts +64 -2
  391. package/src/modules/workflow/recovery-bot.ts +7 -3
  392. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  393. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  394. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  395. package/src/modules/workflow/workflow-hooks.ts +64 -26
  396. package/src/modules/workflow/workflow.service.ts +125 -5
  397. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  398. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  399. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -0
  400. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  401. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  402. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  403. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  404. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  405. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  406. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  407. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +402 -0
  408. package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
  409. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
  410. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  411. package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
  412. package/src/modules/workspace/agent-access.gate.ts +164 -0
  413. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  414. package/src/modules/workspace/agent-upload.store.ts +668 -0
  415. package/src/modules/workspace/routine-write-policy.ts +5 -6
  416. package/src/modules/workspace/session-sink.ts +5 -5
  417. package/src/modules/workspace/set-aside-clone.ts +96 -0
  418. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  419. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  420. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  421. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  422. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  423. package/src/modules/workspace/upload-limits.ts +12 -0
  424. package/src/modules/workspace/workspace.routes.ts +96 -5
  425. package/src/modules/workspace/workspace.service.ts +319 -61
  426. package/src/modules/workspace/workspace.tools.ts +894 -214
  427. package/src/modules/workspace/write-denial.ts +0 -8
  428. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  429. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  430. package/src/modules/write-access/write-access.ts +153 -0
  431. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  432. package/src/shared/column-crypto.ts +218 -0
  433. package/src/shared/domain-errors.ts +31 -0
  434. package/src/shared/git.contract.ts +27 -0
  435. package/src/shared/token-crypto.ts +28 -1
  436. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
  437. package/src/tenancy/static-tenant-source.ts +1 -3
  438. package/src/tenancy/tenant-secrets.ts +5 -1
  439. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  440. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  441. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  442. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  443. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  444. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  445. package/dist/modules/workflow/session-ontology.service.js +0 -147
  446. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  447. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  448. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  449. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  450. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  451. package/dist/shared/kb-layout.d.ts +0 -39
  452. package/dist/shared/kb-layout.d.ts.map +0 -1
  453. package/dist/shared/kb-layout.js +0 -103
  454. package/dist/shared/kb-layout.js.map +0 -1
  455. package/dist/shared/kb-layout.test.d.ts +0 -2
  456. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  457. package/dist/shared/kb-layout.test.js +0 -75
  458. package/dist/shared/kb-layout.test.js.map +0 -1
  459. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  460. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  461. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  462. package/src/modules/workflow/session-ontology.service.ts +0 -183
  463. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  464. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  465. package/src/shared/kb-layout.test.ts +0 -98
  466. package/src/shared/kb-layout.ts +0 -102
@@ -1,16 +1,17 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import nodeFs from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import AdmZip from 'adm-zip';
4
5
  import { ToolError } from '../tool-helpers/tool.contract.js';
5
6
  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';
7
+ import { notifyAgentRead, assertAgentWriteAllowed, SESSION_ID_INPUT, } from './agent-access.gate.js';
7
8
  import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
8
9
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
9
- import { assertBranchProvided } from '../../shared/domain-errors.js';
10
+ import { assertBranchProvided, GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
10
11
  // Leaf-level shared primitive (same exception `workspace.service.ts` already
11
12
  // relies on) — not a workflow service, so this stays inside the module boundary.
12
13
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
13
- import { assertInsideRepo, assertRepoRootNameFreeArgs, normalizePathArgs } from '../kb-fs/repo-path.js';
14
+ import { assertInsideRepo, assertRepoRootNameFree, assertRepoRootNameFreeArgs, isInsideRepo, normalizePathArgs, } from '../kb-fs/repo-path.js';
14
15
  import { GitGuardedFilesystem } from '../kb-fs/git-guarded-filesystem.js';
15
16
  import { assertNoGitInternalsSegment, assertNotGitInternals, hasGitInternalsSegment } from '../../shared/git-internals.js';
16
17
  import { isRolesYamlPath } from '../access-model/roles-yaml-guard.js';
@@ -23,14 +24,16 @@ import { fileTypeOf, needsContent } from './file-readers/content-mode.js';
23
24
  import { createFileReaderRegistry } from './file-readers/file-reader.registry.js';
24
25
  import { DocumentReader } from './file-readers/document-reader.js';
25
26
  import { mcpImageResult } from '@bevel-software/platform-mcp-core';
26
- import { LEGACY_AGENTS_FILE, folderPlaceholderPath, isFolderPlaceholder, isPlatformFile, isPlatformFolder, platformFileCreationRefusal, platformFileNames, platformFileRefusal, platformFolderRefusal, entryExistsMessage, } from '@bevel-software/platform-shared';
27
+ import { folderPlaceholderPath, isFolderPlaceholder, isPlatformFile, isPlatformFolder, platformFileCreationRefusal, platformFileRefusal, platformFileUploadRefusal, platformFolderRefusal, entryExistsMessage, } from '@bevel-software/platform-shared';
27
28
  import { AccessDeniedError } from '../access-model/access-errors.js';
28
29
  import { removeEmptyDirs } from './empty-dirs.js';
29
- import { PROPOSAL_ROUTE_NOTE, rethrowAsWriteDenial } from './write-denial.js';
30
+ import { rethrowAsWriteDenial } from './write-denial.js';
31
+ import { sharedRulesPointer } from '../agent-instructions/shared-file-rules.js';
30
32
  import { notFound, orDeclaredNotFound, orNotFound } from './not-found.js';
31
33
  import { logger } from '../../shared/logging.js';
32
34
  import { printable } from '../../shared/printable.js';
33
35
  import { DestinationTakenError, inspectDestination } from '../../shared/rename-no-replace.js';
36
+ import { isSymlinkZipEntry, isZipNoiseEntry, readZipEntry, zipEntryName, zipEntryNameRefusal, zipEntrySegments, } from './zip-entry-rules.js';
34
37
  const log = logger('workspace-tools');
35
38
  /** How many files `file_stat` counts under a folder before it stops and says so. */
36
39
  const DESCENDANTS_CAP = 10_000;
@@ -122,53 +125,23 @@ async function keepFolderOf(fs, ctx, branch, removedPath, kbDirName) {
122
125
  throw err;
123
126
  }
124
127
  }
125
- /**
126
- * Appended (centrally, in `mount`) to EVERY workspace tool description. The
127
- * platform's managed agent guide sits at the workspace root and documents the
128
- * conventions of that knowledge base; agents (ours and external) should consult
129
- * it before touching files. It rides on every entrypoint — reads (grep/
130
- * list_files/file_stat) included — because any of them can be a session's first
131
- * touch.
132
- *
133
- * `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
134
- * rename still carry one, and the seeder never deletes a file it did not
135
- * expect. Naming both means an agent finds the conventions either way, instead
136
- * of reading none because it looked for the newer name and stopped.
137
- *
138
- * WHEN THE GUIDE HAS BEEN RENAMED the sentence names two files, ours first. The
139
- * second is the organisation's OWN `AGENTS.md`, which on such a deployment is
140
- * ordinary content the platform never touches — and which no harness reads for a
141
- * remote agent, because a remote agent has no checkout. Telling it to read both
142
- * is the only way the conventions the customer actually wrote reach the agent
143
- * working in their knowledge base. Under the default name the wording collapses
144
- * to the one file it has always named.
145
- *
146
- * A FUNCTION of the layout, called when a description is built: the name is a
147
- * deployment setting, and a module-scope string would snapshot the default.
148
- */
149
- function kbConventionsNote(layout) {
150
- const agentsFile = layout.agentsFile ?? LEGACY_AGENTS_FILE;
151
- if (agentsFile === LEGACY_AGENTS_FILE) {
152
- return ' Before your first read or change in a workspace, read `AGENTS.md` at the KB root — or `CLAUDE.md` on a knowledge base seeded before it was renamed — if either exists: it holds the author\'s conventions for this knowledge base, and you should follow them.';
153
- }
154
- return (` Before your first read or change in a workspace, read \`${agentsFile}\` at the KB root, then ` +
155
- '`AGENTS.md` if it also exists (the organisation\'s own conventions) — or `CLAUDE.md` on a knowledge base seeded before it was renamed: together they hold the conventions for this knowledge base, and you should follow them.');
156
- }
157
- /** The platform files as a tool description lists them — the guide under its own name. */
158
- function platformFileList(layout) {
159
- return platformFileNames(layout)
160
- .map((name) => `\`${name}\``)
161
- .join(', ');
162
- }
163
128
  const int = (description) => ({ type: 'integer', description });
164
129
  const str = (description) => ({ type: 'string', description });
165
130
  /**
166
- * Where pictures go, on the two tools that write pages. An agent in core cannot
167
- * upload bytes yet (TODOS.md), but it can write the page with the link a person
168
- * will satisfy, and this sentence is what keeps every page it writes on the
169
- * README's convention: images beside the page, linked relatively.
131
+ * The upload route, named on every tool that takes content as a JSON string.
132
+ *
133
+ * ONE sentence, and the tools' own: it is what stops the three failures the
134
+ * route was built for. An agent landing 27 files read each one and typed it
135
+ * out again as a tool argument: a 37 KB write was truncated mid-answer, a page
136
+ * of regex backslashes failed to parse as a JSON parameter, and a PNG could not
137
+ * be sent at all. None of that is discoverable from a refusal — a truncated
138
+ * write reports success — so the tools that invite it name where the bytes
139
+ * should go instead, in the description itself, for a client that reads
140
+ * nothing else. WHY, and how the route is used, is one of the shared rules
141
+ * (`agent-instructions/shared-file-rules.ts`): said in full on three
142
+ * descriptions it took each of them past the length a client cuts at.
170
143
  */
171
- const IMAGE_CONVENTION_NOTE = ' Images: keep them in an `assets/` folder next to the page that uses them and link them with a relative path, e.g. `![Approval screen](./assets/approval-screen.png)`; the page renders them inline.';
144
+ const UPLOAD_ROUTE_NOTE = ' Large, escape-heavy or binary content does not go through here: use `request_file_upload` + `apply_file_upload`.';
172
145
  /**
173
146
  * A path input that names the clone folder, and says what happens when it does
174
147
  * not. The tools are rooted at the WORKSPACE dir, one level above the git clone,
@@ -202,6 +175,9 @@ const RESERVED_ROOT_NAME_TARGETS = {
202
175
  copy_file: ['dest'],
203
176
  move_file: ['dest'],
204
177
  unzip: ['destination'],
178
+ // The folder the upload lands in. Each of its own paths is checked again
179
+ // inside the handler — an archive chooses its entry names, not the caller.
180
+ apply_file_upload: ['destination'],
205
181
  };
206
182
  function asText(content) {
207
183
  return typeof content === 'string' ? content : content.toString('utf8');
@@ -219,14 +195,6 @@ function asBytes(content) {
219
195
  * `read_file`, which extracts unbudgeted) will cover the rest.
220
196
  */
221
197
  const UNCACHED_DOCS_PER_GREP = 20;
222
- /**
223
- * THE binary capability contract, stated once and appended (in `mount`) to
224
- * every file tool's description — which is also what `tools_info` returns.
225
- * The split it states is enforced by the reader registry: the text tools
226
- * refuse what their reader marks not `textEditable` (and binary content under
227
- * any name) with a `binary_not_writable` refusal; the byte tools never look.
228
- */
229
- export const CONTENT_RULE = ' Content rule (the same on every file tool): read_file returns text for text files and extracted text for documents (.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf, .eml/.msg); write_file, write_files and edit_file accept TEXT only — they refuse documents, images, archives and other binary files (legacy .doc/.ppt/.xls included) with kind `binary_not_writable`, naming the file\'s kind and the tool to use instead; copy_file, move_file, delete_file and unzip act on bytes of any kind; new binary content arrives through upload (`request_upload_token` + `apply_upload` where offered, otherwise Upload in the app). file_stat reports `contentMode` (`text` | `document` | `binary`) so you can decide before acting.';
230
198
  /** What a `binary_not_writable` refusal points to, in the order to try them. */
231
199
  const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'];
232
200
  /**
@@ -237,7 +205,7 @@ const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'];
237
205
  */
238
206
  function binaryNotWritable(fileKind, explanation) {
239
207
  return new ToolError(`${explanation} [binary_not_writable: this file's kind is ${fileKind}; write_file, write_files and edit_file accept text only. ` +
240
- 'Use upload for new bytes (`request_upload_token` + `apply_upload` where offered, otherwise Upload in the app), ' +
208
+ 'Use upload for new bytes (`request_file_upload` + `apply_file_upload`, or Upload in the app), ' +
241
209
  'or copy_file / move_file to place bytes that are already in the workspace.]', 415, { kind: 'binary_not_writable', fileKind, useInstead: [...BINARY_USE_INSTEAD] });
242
210
  }
243
211
  /** The generic why, for a reader without format-specific refusal copy. */
@@ -314,11 +282,6 @@ const WRITE_MODE_INPUT = {
314
282
  'holds something; `overwrite` replaces what is there, and creates the file when there is nothing; `update` replaces an ' +
315
283
  'EXISTING file and refuses (`missing`) a path that holds nothing.',
316
284
  };
317
- /** The same three modes, said once, for both tool descriptions. */
318
- const WRITE_MODE_NOTE = ' `mode` decides what may happen at a path and DEFAULTS TO `create`: `create` writes a new file and refuses a path that ' +
319
- 'already exists (`exists`, with the path — pass `mode: overwrite` to replace it), `overwrite` replaces what is there ' +
320
- '(creating it if there is nothing), `update` replaces an existing file and refuses a path that does not exist (`missing`). ' +
321
- 'A refused path is left exactly as it was.';
322
285
  /** The refusal `create` gives on a path that already holds something. */
323
286
  function pathExists(path) {
324
287
  return new ToolError(`"${displayPath(path)}" already exists — pass mode: overwrite to replace it, or write to a different path.`, 409, { code: 'exists', path });
@@ -342,6 +305,144 @@ function decideWrite(mode, path, exists) {
342
305
  return 'updated';
343
306
  return exists ? 'replaced' : 'created';
344
307
  }
308
+ /**
309
+ * How many of an upload's paths the answer NAMES before it stops and says how
310
+ * many there were. A 300-file zip's full outcome list is pages of text an agent
311
+ * pays for on every call; 25 is enough to see the shape of what happened, and
312
+ * `total` plus `truncated` say that there is more. `all: true` asks for the
313
+ * rest, for a caller that really does have to read each one.
314
+ */
315
+ const APPLY_ANSWER_CAP = 25;
316
+ /**
317
+ * How many entries of one uploaded archive are landed, and how many bytes of
318
+ * uncompressed content in total.
319
+ *
320
+ * Tighter than `unzip`'s own caps on purpose. An apply lands its whole set as
321
+ * ONE commit, which means every entry's bytes are held in memory at once —
322
+ * the property that makes the commit atomic is the one that makes a zip bomb
323
+ * expensive. The upload itself is already bounded by the deployment's upload
324
+ * limit; these bound what that upload is allowed to expand into.
325
+ */
326
+ const APPLY_MAX_ENTRIES = 5_000;
327
+ const APPLY_MAX_TOTAL_BYTES = 128 * 1024 * 1024; // 128 MB uncompressed
328
+ /**
329
+ * Turn a stored upload into one planned path per file.
330
+ *
331
+ * A single file is one path: the destination plus the name it was sent with.
332
+ * A zip is one path per member, with the member's folder structure kept under
333
+ * the destination — judged by the same entry rules `unzip` applies
334
+ * (`zip-entry-rules.ts`), plus one `unzip` does not have: an entry that is a
335
+ * symbolic LINK is refused outright. A zip stores a link as a member whose
336
+ * bytes are its target text, so a reader that ignored the mode bits would
337
+ * write that text out as a file — content nobody sent, under a name that was
338
+ * meant to point elsewhere.
339
+ */
340
+ /** The refusal an entry gets when the archive would expand past what one commit lands. */
341
+ function tooLargeToApply() {
342
+ return `This archive expands past the ${APPLY_MAX_TOTAL_BYTES} byte total the apply lands in one commit; this entry was not applied.`;
343
+ }
344
+ async function planUpload(upload, destination, kbDirName) {
345
+ const bytes = await nodeFs.readFile(upload.absolutePath);
346
+ if (upload.kind !== 'zip') {
347
+ return [{ path: `${destination}/${upload.filename}`, content: bytes }];
348
+ }
349
+ let zip;
350
+ try {
351
+ zip = new AdmZip(bytes);
352
+ }
353
+ catch (err) {
354
+ throw new ToolError(`"${upload.filename}" could not be opened as a .zip archive: ${err instanceof Error ? err.message : String(err)}`, 422, { code: 'unreadable_archive' });
355
+ }
356
+ const planned = [];
357
+ let seen = 0;
358
+ let totalBytes = 0;
359
+ for (const entry of zip.getEntries()) {
360
+ const rawName = zipEntryName(entry.entryName);
361
+ if (isZipNoiseEntry(rawName))
362
+ continue;
363
+ if (seen >= APPLY_MAX_ENTRIES) {
364
+ planned.push({
365
+ path: rawName || '(empty)',
366
+ error: 'too_many_entries',
367
+ message: `This archive holds more than ${APPLY_MAX_ENTRIES} entries; the rest were not applied.`,
368
+ });
369
+ continue;
370
+ }
371
+ seen++;
372
+ const nameRefusal = zipEntryNameRefusal(rawName);
373
+ if (nameRefusal !== null) {
374
+ planned.push({ path: rawName || '(empty)', error: 'invalid_entry', message: nameRefusal });
375
+ continue;
376
+ }
377
+ if (isSymlinkZipEntry(entry)) {
378
+ planned.push({
379
+ path: rawName,
380
+ error: 'link',
381
+ message: `"${rawName}" is a symbolic link, not a file; an upload lands files, never links.`,
382
+ });
383
+ continue;
384
+ }
385
+ // A folder comes into being with the files under it (the write path mkdirs
386
+ // each parent), so a directory member has nothing of its own to land.
387
+ if (entry.isDirectory)
388
+ continue;
389
+ const segments = zipEntrySegments(rawName);
390
+ const target = [destination, ...segments].join('/');
391
+ // Belt and braces: `zipEntryNameRefusal` already refuses a `..` segment
392
+ // and a root-anchored name, so nothing should reach here that climbs out.
393
+ // The check stays because the cost of being wrong about that is bytes
394
+ // landing outside the folder the caller named.
395
+ if (!target.startsWith(`${destination}/`) || !isInsideRepo(target, kbDirName)) {
396
+ planned.push({ path: rawName, error: 'invalid_entry', message: 'Path escapes destination' });
397
+ continue;
398
+ }
399
+ // Through the one bounded reader `unzip` uses too, capped at what is left
400
+ // of the budget: a deflate stream can expand a thousandfold, and the
401
+ // header's declared size is the archive's claim, not a fact — an entry
402
+ // declaring ZERO would otherwise be inflated with no cap at all (see
403
+ // `readZipEntry`). A read that fails is this entry's outcome and no more.
404
+ const read = readZipEntry(entry, APPLY_MAX_TOTAL_BYTES - totalBytes);
405
+ if (!read.ok) {
406
+ planned.push(read.reason === 'too_large'
407
+ ? { path: rawName, error: 'too_large', message: tooLargeToApply() }
408
+ : { path: rawName, error: 'unreadable_entry', message: `"${rawName}" could not be read: ${read.detail}.` });
409
+ continue;
410
+ }
411
+ totalBytes += read.data.byteLength;
412
+ planned.push({ path: target, content: read.data });
413
+ }
414
+ return planned;
415
+ }
416
+ /**
417
+ * Record on `entry` that this path was refused, saying what the gate that
418
+ * refused it said. Four kinds of refusal count as one path's outcome: a
419
+ * typed tool refusal (`exists`, `platform_file`, the mode gate), a permission
420
+ * refusal (the caller may not write this path, where the DESTINATION was
421
+ * writable), the git folder in any spelling, and a path-shape refusal from the
422
+ * repository rules. Anything else
423
+ * is not a verdict about this path — it is a gate failing — so it travels on
424
+ * and the whole apply fails loudly, exactly as it does in `write_files`.
425
+ */
426
+ function refuseEntry(entry, err) {
427
+ entry.outcome = 'refused';
428
+ if (err instanceof ToolError) {
429
+ const details = (err.details ?? {});
430
+ entry.error = details.code ?? details.kind ?? 'refused';
431
+ entry.message = err.message;
432
+ return;
433
+ }
434
+ if (err instanceof AccessDeniedError) {
435
+ entry.error = 'write-denied';
436
+ entry.message = err.message;
437
+ return;
438
+ }
439
+ if (err instanceof GitInternalsError || err instanceof WorkflowValidationError) {
440
+ entry.error = err.payload?.kind ?? 'refused';
441
+ entry.message = err.message;
442
+ return;
443
+ }
444
+ throw err;
445
+ }
345
446
  /** The three modes, as a set the handler can check a raw argument against. */
346
447
  const WRITE_MODES = ['create', 'overwrite', 'update'];
347
448
  /**
@@ -431,7 +532,7 @@ async function searchRootKind(fs, path) {
431
532
  }
432
533
  }
433
534
  /** JS grep over the workspace tree (read methods only) — bounded by match + depth caps. */
434
- async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead, docs) {
535
+ async function grepWalk(fs, dir, re, out, max, depth, gate, notifyRead, docs) {
435
536
  if (out.length >= max || depth > 12)
436
537
  return;
437
538
  let entries;
@@ -453,13 +554,13 @@ async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead,
453
554
  continue;
454
555
  const p = dir ? `${dir}/${e.name}` : e.name;
455
556
  if (e.type === 'directory') {
456
- await grepWalk(fs, p, re, out, max, depth + 1, gate, recordOntologyRead, docs);
557
+ await grepWalk(fs, p, re, out, max, depth + 1, gate, notifyRead, docs);
457
558
  }
458
559
  else {
459
- // Opening a file under a named ontology is a read of that ontology — even
460
- // for a root-level grep that resolves to a neutral root. Record it so a
461
- // cross-ontology grep poisons later writes (closes the read-leak).
462
- await recordOntologyRead(p);
560
+ // Opening a file is a read of it, even when the walk started at a root
561
+ // the read hook was already told about — so every file the walk opens
562
+ // reaches the hook by name (closes the read-leak).
563
+ await notifyRead(p);
463
564
  // A file the walk cannot read is silently skipped: one unreadable entry
464
565
  // must not fail a search over the whole tree.
465
566
  try {
@@ -489,6 +590,17 @@ const BATCH_SAVE_WARNINGS_OUTPUT = {
489
590
  'The writes still happened.',
490
591
  items: { type: 'object' },
491
592
  };
593
+ /**
594
+ * The `sessionId` property inside a BUILT tool def's input schema, or
595
+ * `undefined` for a tool that declares none. `toolDef` wraps the flat inputs
596
+ * under `body` and copies the schema it is given, so a note registered after
597
+ * the tools were built has to be written here rather than onto the shared
598
+ * `SESSION_ID_INPUT` constant.
599
+ */
600
+ function sessionIdInputOf(def) {
601
+ const inputs = def.inputs;
602
+ return inputs?.properties?.body?.properties?.sessionId;
603
+ }
492
604
  /**
493
605
  * Workspace domain tools: the file primitives (replacing Mastra's auto-injected
494
606
  * Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
@@ -498,7 +610,7 @@ const BATCH_SAVE_WARNINGS_OUTPUT = {
498
610
  * method), so they're implemented here. File ops are `both`; `execute_command`
499
611
  * is INTERNAL-only (arbitrary shell as the caller is too dangerous to expose).
500
612
  */
501
- export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kb, sessionOntologyGate, writePolicy, sessionSink,
613
+ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kb, agentAccessGate, writePolicy, sessionSink,
502
614
  /**
503
615
  * Save-time skill check (see `AllowedToolsChecker`): a write to a SKILL.md
504
616
  * returns `warnings` for `allowed-tools` entries naming no visible tool.
@@ -511,7 +623,16 @@ skillSaveCheck,
511
623
  * would be made on. Optional so tool harnesses need not wire it; the read
512
624
  * verdict alone then decides, which differs only at a root.
513
625
  */
514
- changeGate) {
626
+ changeGate,
627
+ /**
628
+ * The upload-token store behind `request_file_upload` / `apply_file_upload`
629
+ * — the route an agent lands bytes by, without their content passing
630
+ * through the model. Optional for the same reason the two above are: a tool
631
+ * harness that is about the file primitives need not stand one up. Every
632
+ * real composition wires it (`create-core-server.ts`), and without it the
633
+ * two tools are not mounted at all rather than mounted and broken.
634
+ */
635
+ uploads) {
515
636
  const { kbDirName } = kb;
516
637
  /**
517
638
  * The one extension→reader registry every read-shaped decision routes
@@ -582,7 +703,7 @@ changeGate) {
582
703
  * Every spelling first, then the resolved form against the branch's
583
704
  * workspace, so a link into the folder is refused the same way. The resolved
584
705
  * check only runs on a branch that is already cloned: bootstrapping a clone
585
- * here would happen before the handler's access and ontology gates. A branch
706
+ * here would happen before the handler's access and agent-access gates. A branch
586
707
  * not cloned yet (or that does not resolve) is left to the handler; the
587
708
  * filesystem refuses again underneath regardless.
588
709
  */
@@ -605,7 +726,7 @@ changeGate) {
605
726
  * The branch's workspace root, to judge a spelling against what is on disk
606
727
  * — or null when there is nothing to judge it against yet. Only a branch
607
728
  * ALREADY cloned is used: bootstrapping one here would clone before the
608
- * handler's access and ontology gates have had their say.
729
+ * handler's access and agent-access gates have had their say.
609
730
  */
610
731
  const gitCheckRootFor = async (args, ctx) => {
611
732
  if (typeof args.branch !== 'string' || args.branch === '')
@@ -672,6 +793,112 @@ changeGate) {
672
793
  ]);
673
794
  return { read, write, download, owner };
674
795
  };
796
+ /** Whether any one of the caller's four verdicts differs between the two sides of a preview. */
797
+ const verbsDiffer = (before, after) => Object.keys(before).some((v) => before[v] !== after[v]);
798
+ /**
799
+ * Why `copy_file` will not take a folder. One sentence, said by the dry
800
+ * run and by the call itself, so the preflight and the execution never
801
+ * disagree — the rule this whole section is built on.
802
+ */
803
+ 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.`;
804
+ /**
805
+ * The caller's verdicts at `dest` as they will be once `src` has been
806
+ * moved (or, with `sourceRemains`, copied) there — the `after` half of a
807
+ * move's or copy's preview.
808
+ *
809
+ * `accessAt(dest)` is the wrong answer to that question for a folder: the
810
+ * destination on disk has neither the folder nor the `access.md` files it
811
+ * carries, so it describes the destination's PARENT. A rename of a folder
812
+ * that names the caller owner in its own `access.md` therefore warned
813
+ * about losing owner access the move was about to hand straight back, and
814
+ * a warning that is wrong is a warning people learn to click through.
815
+ *
816
+ * Preview only, like everything else in this section: it answers what the
817
+ * caller WILL have, never whether they may do it. The write verdicts that
818
+ * gate the move are `writeBlocked` and the lock gate, both of which read
819
+ * the tree as it is.
820
+ */
821
+ const accessAfter = async (branch, ctx, src, dest, opts) => {
822
+ const from = toKbRelative(src, kbDirName);
823
+ const to = toKbRelative(dest, kbDirName);
824
+ // Outside the repository there are no rules to carry, and none to land
825
+ // among — the same answer `accessAt` gives for such a path.
826
+ if (from === null || to === null)
827
+ return accessAt(branch, ctx, dest);
828
+ return accessControl.previewAccessAfterRelocation(workspaceIdForBranch(branch), ctx.user.email, from, to, opts);
829
+ };
830
+ /**
831
+ * What `copy_file`'s dry run answers: the same impact shape `move_file`
832
+ * previews, over a copy's own rules.
833
+ *
834
+ * A copy LEAVES the source where it is, so the rules it carries are
835
+ * duplicated rather than relocated (`sourceRemains`) — otherwise the two
836
+ * previews ask the same question. The order of the refusals is `copy_file`'s
837
+ * own and is load-bearing: the write verdict on the destination outranks
838
+ * "that name is taken", because a caller who may not write a folder must
839
+ * not learn what is in it from a refusal.
840
+ *
841
+ * A folder source is reported as the refusal it is. `copy_file` copies one
842
+ * file; the preview says so rather than promising a copy that would fail,
843
+ * and still answers `access.after` for the folder it was asked about.
844
+ *
845
+ * NOTHING is probed on disk until the write verdict on the destination has
846
+ * been taken — not the destination, and not the source either, which is the
847
+ * order the call itself keeps at length: a caller who may not write there
848
+ * gets the same refusal whether the source is a file, a folder, or missing
849
+ * altogether. Probing the source first put a 404 in front of that 403 and
850
+ * handed a denied caller the source's kind and its file count. So a refused
851
+ * preview answers `allowed: false` with the sentence and no `kind` or
852
+ * `descendants`: those are the half of the impact the caller has to have
853
+ * earned. The two `access` sides are the caller's own four verbs and tell
854
+ * them nothing they could not ask `file_stat` for.
855
+ */
856
+ const copyImpact = async (branch, ctx, src, dest) => {
857
+ const [before, after, blocked] = await Promise.all([
858
+ accessAt(branch, ctx, src),
859
+ accessAfter(branch, ctx, src, dest, { sourceRemains: true }),
860
+ writeBlocked(branch, ctx, [dest]),
861
+ ]);
862
+ const access = { before, after };
863
+ const accessChanges = verbsDiffer(before, after);
864
+ if (blocked.length > 0) {
865
+ return {
866
+ src,
867
+ dest,
868
+ access,
869
+ accessChanges,
870
+ allowed: false,
871
+ reason: `You may not write "${dest}", so the copy cannot run.`,
872
+ dryRun: true,
873
+ copied: false,
874
+ };
875
+ }
876
+ const fs = await ctx.getFilesystem(branch);
877
+ const kind = await kindOf(fs, src);
878
+ if (kind === null)
879
+ throw notFound(src, 'Nothing to copy');
880
+ const srcFiles = kind === 'folder' ? (await filesUnder(fs, src)).files : [src];
881
+ const occupiedBy = await existingAt(await workspaceRoot(branch, ctx), dest);
882
+ const reason = occupiedBy !== null
883
+ ? entryExistsMessage(occupiedBy, dest)
884
+ : kind === 'folder'
885
+ ? folderCopyRefusal(src)
886
+ : undefined;
887
+ return {
888
+ src,
889
+ dest,
890
+ kind,
891
+ // The placeholder travels with its folder, but it is never content —
892
+ // counted as `move_file` counts it.
893
+ descendants: srcFiles.filter((f) => !isFolderPlaceholder(f)).length,
894
+ access,
895
+ accessChanges,
896
+ allowed: reason === undefined,
897
+ ...(reason !== undefined ? { reason } : {}),
898
+ dryRun: true,
899
+ copied: false,
900
+ };
901
+ };
675
902
  /**
676
903
  * The paths among `paths` the caller may NOT write, judged exactly as the
677
904
  * lock gate judges them (`WorkflowService.acquireLock`): on a protected
@@ -947,10 +1174,14 @@ changeGate) {
947
1174
  const linkRefusal = (path) => `"${path}" is a symbolic link; the agent tools never follow or remove links.`;
948
1175
  const mount = (spec) => {
949
1176
  const path = `/api/agent/tools/${spec.name}`;
950
- // Every workspace entrypoint carries the agent-guide reminder, every file
951
- // tool the one content rule, and every tool a permission can refuse the
952
- // proposal route — appended once here so no tool (especially the
953
- // read-only ones a session hits first) can miss them.
1177
+ // Every description ends with ONE sentence pointing at the rules these
1178
+ // tools share — the content rule, the agent guide, the write modes, the
1179
+ // dry-run protocol, the proposal route. They used to be appended here in
1180
+ // FULL, which made a description several thousand characters of text the
1181
+ // agent had already read on the tool above, and clients cut a long
1182
+ // description from the END, where what is specific to the tool sits. The
1183
+ // rules themselves are in the handshake instructions and in the managed
1184
+ // guide (see `shared-file-rules.ts`), stated once and from one text.
954
1185
  // Whether a call to this tool MUST name a branch, read off the tool's own
955
1186
  // declaration rather than assumed of the family. Every tool mounted here
956
1187
  // requires `branch` today; keying on the schema means a tool that declares
@@ -958,9 +1189,8 @@ changeGate) {
958
1189
  // own route) is not handed a refusal it never asked for.
959
1190
  const requiresBranch = (spec.inputs.required ?? []).includes('branch');
960
1191
  const describe = () => (typeof spec.description === 'function' ? spec.description() : spec.description) +
961
- (spec.proposable ? PROPOSAL_ROUTE_NOTE : '') +
962
- (spec.fileTool === false ? '' : CONTENT_RULE) +
963
- kbConventionsNote(kb.layout);
1192
+ (spec.gated ? agentAccessGate.notes.gatedToolNote() : '') +
1193
+ sharedRulesPointer(kb.layout);
964
1194
  const def = toolDef({
965
1195
  name: spec.name,
966
1196
  description: describe(),
@@ -972,16 +1202,33 @@ changeGate) {
972
1202
  registry.registerInternalTool(def);
973
1203
  if (!spec.internalOnly)
974
1204
  registry.registerExternalTool(def);
975
- // The catalog FOLLOWS the layout. The conventions reminder above names the
976
- // guide, and several descriptions name it again as a platform file, so the
977
- // save that completes first-run setup — which applies the names the admin
978
- // just chose, in that same request, without a restart — must be able to
979
- // move the text with them. Rewritten in place: the registry holds this
980
- // object, both surfaces hold the same one, and re-registering would be a
981
- // duplicate name.
982
- kb.onLayoutApplied(() => {
1205
+ /**
1206
+ * What the agent reads about this tool, rebuilt from whatever is in effect
1207
+ * NOW: the layout's names and the notes the deployment registered.
1208
+ *
1209
+ * The catalog FOLLOWS the layout. The conventions reminder above names the
1210
+ * guide, and several descriptions name it again as a platform file, so the
1211
+ * save that completes first-run setup — which applies the names the admin
1212
+ * just chose, in that same request, without a restart — must be able to
1213
+ * move the text with them. Rewritten in place: the registry holds this
1214
+ * object, both surfaces hold the same one, and re-registering would be a
1215
+ * duplicate name. The `sessionId` input is rewritten on the DEF rather
1216
+ * than on `SESSION_ID_INPUT`, because `toolDef` copies the schema it is
1217
+ * given.
1218
+ */
1219
+ const redescribe = () => {
983
1220
  def.description = describe();
984
- });
1221
+ const sessionId = sessionIdInputOf(def);
1222
+ if (sessionId)
1223
+ sessionId.description = agentAccessGate.notes.sessionIdDescription();
1224
+ };
1225
+ // Once for a note registered BEFORE the tools were mounted (the `sessionId`
1226
+ // input is copied by `toolDef`, so it carries the bare default until this
1227
+ // runs), and then on every later change: a note may be registered AFTER
1228
+ // the mount, from the tool-surface hook an overlay registers on.
1229
+ redescribe();
1230
+ kb.onLayoutApplied(redescribe);
1231
+ agentAccessGate.notes.onChange(redescribe);
985
1232
  // Internal-only tools (e.g. `execute_command`) keep their route mounted —
986
1233
  // our agent calls it over the same loopback — but gate it to internal-source
987
1234
  // callers so an external connection key can't invoke it by name.
@@ -1039,22 +1286,20 @@ changeGate) {
1039
1286
  }, { write: spec.write }));
1040
1287
  };
1041
1288
  // ── session bootstrap (external agents) ─────────────────────────────────
1042
- // Every read/write tool below scopes the ontology-session boundary off a
1043
- // `sessionId`. The in-process agent carries its thread id, but an external
1044
- // agent has no ambient run id and so cannot satisfy the gate until it has
1045
- // one. This mints that id up front (called ONCE); the MCP proxy then threads
1046
- // it onto every later gated call via its sessionId-output continuity
1289
+ // Every read/write tool below takes a `sessionId`: the conversation the
1290
+ // call belongs to, which is what a deployment's hooks scope their rule to.
1291
+ // The in-process agent carries its thread id, but an external agent has no
1292
+ // ambient run id, so this mints one up front (called ONCE); the MCP proxy
1293
+ // then threads it onto every later call via its sessionId-output continuity
1047
1294
  // convention. EXTERNAL-ONLY (not registered internal): the in-process agent
1048
1295
  // already supplies its session id and ignores any body value.
1049
1296
  //
1050
1297
  // WHAT the minted id is backed by is the `ISessionSink` port's business
1051
1298
  // (session-sink.ts). In the enterprise app it is a REAL chat-thread id, so
1052
- // the SAME id works end to end: KB reads scope the ontology boundary under
1053
- // it, AND `ask` accepts it (its sessionId IS a chat thread, resolved via
1054
- // getThread) — that unification is what stops a caller reading from one
1055
- // ontology and then having `ask` write into another. In a core-only
1056
- // deployment (no chat/ask) the default sink mints a bare id, which is all
1057
- // the ontology gate needs.
1299
+ // the SAME id works end to end: the file tools take it AND `ask` accepts it
1300
+ // (its sessionId IS a chat thread, resolved via getThread), so a run's reads
1301
+ // and its questions are one conversation rather than two. In a core-only
1302
+ // deployment (no chat/ask) the default sink mints a bare id.
1058
1303
  //
1059
1304
  // The description tells the caller that retrying is safe, and that is a
1060
1305
  // property of the sink rather than a promise this route makes on its own:
@@ -1066,7 +1311,7 @@ changeGate) {
1066
1311
  // transport hiccup on its first call as an unrecoverable start.
1067
1312
  const startSessionDef = toolDef({
1068
1313
  name: 'start_session',
1069
- 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 }`.',
1314
+ 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 }`.',
1070
1315
  path: '/api/agent/tools/start_session',
1071
1316
  inputs: { type: 'object', properties: {}, additionalProperties: false },
1072
1317
  outputs: {
@@ -1078,10 +1323,10 @@ changeGate) {
1078
1323
  });
1079
1324
  registry.registerExternalTool(startSessionDef);
1080
1325
  // Mint the session id via the sink and return it (see comment above: one id
1081
- // spans start_session -> reads -> ask, closing the ontology-pollution gap).
1326
+ // spans start_session -> reads -> ask).
1082
1327
  router.post('/agent/tools/start_session', toolAuth,
1083
1328
  // External-only: an internal token already carries its run's sessionId, so
1084
- // minting a new thread mid-run would reset the ontology boundary. Note
1329
+ // minting a new thread mid-run would split one run in two. Note
1085
1330
  // "external" includes the MCP proxy's `externalProxy` loopback tokens
1086
1331
  // (OAuth/JWT MCP sessions) — the verifier resolves those to
1087
1332
  // `source: 'external'`, and one such session may legitimately mint several
@@ -1093,8 +1338,14 @@ changeGate) {
1093
1338
  // ── reads ──────────────────────────────────────────────────────────────
1094
1339
  mount({
1095
1340
  name: 'read_file',
1096
- 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.' +
1097
- ONTOLOGY_BOUNDARY_NOTE,
1341
+ gated: true,
1342
+ description: 'Read a workspace file as text. Returns `{ path, content }`. What comes back for a document, an email file, an image ' +
1343
+ 'or any other binary file is the content rule\'s business (see the shared rules): text files as text, documents and ' +
1344
+ 'email files as extracted text, an image as the picture itself, anything else as a one-line description. ' +
1345
+ 'Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored ' +
1346
+ 'for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. ' +
1347
+ 'It also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: such a ref belongs to no ' +
1348
+ 'workspace, so `branch` is ignored for it.',
1098
1349
  inputs: {
1099
1350
  type: 'object',
1100
1351
  properties: {
@@ -1120,12 +1371,12 @@ changeGate) {
1120
1371
  if (spillStore.isSpillRef(p)) {
1121
1372
  return { path: p, content: await spillStore.read(p, offset, limit) };
1122
1373
  }
1123
- await recordOntologyRead(sessionOntologyGate, ctx, p);
1374
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, p);
1124
1375
  await assertCanRead(readGateFor(a.branch, ctx), p);
1125
1376
  const fs = await ctx.getFilesystem(a.branch);
1126
1377
  // Reading (extraction, image and binary handling included) happens AFTER
1127
- // the access gate and the ontology-read recording above — a document
1128
- // read is still a KB read. ONE registry dispatch picks the reader by
1378
+ // the access gate and the read hook above — a document read is still a
1379
+ // KB read. ONE registry dispatch picks the reader by
1129
1380
  // extension; everything below just maps its ReadResult onto the tool's
1130
1381
  // result shape.
1131
1382
  const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
@@ -1157,8 +1408,8 @@ changeGate) {
1157
1408
  });
1158
1409
  mount({
1159
1410
  name: 'list_files',
1160
- 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.` +
1161
- ONTOLOGY_BOUNDARY_NOTE,
1411
+ gated: true,
1412
+ 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.`,
1162
1413
  inputs: {
1163
1414
  type: 'object',
1164
1415
  properties: {
@@ -1188,7 +1439,7 @@ changeGate) {
1188
1439
  write: false,
1189
1440
  handler: async (a, ctx) => {
1190
1441
  const dir = a.path || '';
1191
- await recordOntologyRead(sessionOntologyGate, ctx, dir);
1442
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, dir);
1192
1443
  const fs = await ctx.getFilesystem(a.branch);
1193
1444
  const entries = withoutPlaceholder((await fs.readdir(dir || '.')));
1194
1445
  const filtered = await filterReadableEntries(readGateFor(a.branch, ctx), dir, entries);
@@ -1197,13 +1448,18 @@ changeGate) {
1197
1448
  });
1198
1449
  mount({
1199
1450
  name: 'file_stat',
1200
- 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`.' +
1201
- ' Every entry also reports what you may DO with it. ' +
1202
- `\`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. ` +
1203
- '`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. ' +
1204
- '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. ' +
1205
- 'Call this before a move or delete to see what it would touch.' +
1206
- ONTOLOGY_BOUNDARY_NOTE,
1451
+ gated: true,
1452
+ description: 'Get a file/directory\'s metadata (name, type, size, …) without returning content, and what you may DO with it. ' +
1453
+ 'A file also reports `contentMode`, `kind`, `mime`, `mimeSource` and `textEditable` — decided by the same readers ' +
1454
+ 'read_file, grep and the write tools use, so an extensionless text file is `text/plain`. ' +
1455
+ '`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to ' +
1456
+ 'learn why, and who else holds each verb. ' +
1457
+ 'Call this before a move or delete: `managed`, `movable` and `deletable` answer the shared rules on what these tools ' +
1458
+ 'never move or delete, judged like the dry runs (on a draft branch writes are not gated); `movable` judges the SOURCE ' +
1459
+ 'side only, so the destination still wants a `move_file` dry run. ' +
1460
+ 'For a folder, `descendants` counts the files under it at any depth; counting stops at 10000 and ' +
1461
+ '`descendantsTruncated` says so, past which `movable` and `deletable` are false — a folder that large was not judged ' +
1462
+ 'in full, so run the `move_file` or `delete_folder` dry run for the real verdict.',
1207
1463
  inputs: {
1208
1464
  type: 'object',
1209
1465
  properties: {
@@ -1276,7 +1532,7 @@ changeGate) {
1276
1532
  handler: async (a, ctx) => {
1277
1533
  const p = a.path;
1278
1534
  const branch = a.branch;
1279
- await recordOntologyRead(sessionOntologyGate, ctx, p);
1535
+ await notifyAgentRead(agentAccessGate, ctx, branch, p);
1280
1536
  await assertCanRead(readGateFor(branch, ctx), p);
1281
1537
  // Nothing there is a 404, and the placeholder — never content — gets
1282
1538
  // exactly that answer: the one every file tool gives (see not-found.ts).
@@ -1378,8 +1634,8 @@ changeGate) {
1378
1634
  });
1379
1635
  mount({
1380
1636
  name: 'grep',
1381
- 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).' +
1382
- ONTOLOGY_BOUNDARY_NOTE,
1637
+ gated: true,
1638
+ 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).',
1383
1639
  inputs: {
1384
1640
  type: 'object',
1385
1641
  properties: {
@@ -1430,11 +1686,10 @@ changeGate) {
1430
1686
  // (an empty path is the handler's to explain), and here it would
1431
1687
  // otherwise name the workspace directory by another spelling.
1432
1688
  const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
1433
- // The search root itself is checked here (fail-closed for an agent grep on
1434
- // a named subtree with no sessionId); each file the walk actually opens is
1435
- // recorded per-file below, so a root-level grep that reaches into multiple
1436
- // ontologies still records each one (and can poison later writes).
1437
- await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
1689
+ // The search root itself goes to the read hook here; each file the walk
1690
+ // actually opens goes to it per-file below, so a hook sees every path a
1691
+ // grep reached rather than only the root it started from.
1692
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, searchRoot);
1438
1693
  const fs = await ctx.getFilesystem(a.branch);
1439
1694
  const gate = readGateFor(a.branch, ctx);
1440
1695
  const out = [];
@@ -1452,7 +1707,7 @@ changeGate) {
1452
1707
  /** Why a single-file search found nothing, when "no matches" would be a lie. */
1453
1708
  let fileNote;
1454
1709
  if (kind === 'directory') {
1455
- await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) => recordOntologyRead(sessionOntologyGate, ctx, p), docs);
1710
+ await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) => notifyAgentRead(agentAccessGate, ctx, a.branch, p), docs);
1456
1711
  }
1457
1712
  else {
1458
1713
  // Not a directory: the permission verdict comes BEFORE every other
@@ -1491,11 +1746,10 @@ changeGate) {
1491
1746
  // ── writes (through the lock/commit pipeline) ───────────────────────────
1492
1747
  mount({
1493
1748
  name: 'write_file',
1749
+ gated: true,
1494
1750
  description: 'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
1495
1751
  '`created`, `replaced` or `updated`.' +
1496
- WRITE_MODE_NOTE +
1497
- IMAGE_CONVENTION_NOTE +
1498
- ONTOLOGY_BOUNDARY_NOTE,
1752
+ UPLOAD_ROUTE_NOTE,
1499
1753
  inputs: {
1500
1754
  type: 'object',
1501
1755
  properties: {
@@ -1531,7 +1785,7 @@ changeGate) {
1531
1785
  // (today only `watchlist_check`, to `.html`). Unrestricted sessions pass straight
1532
1786
  // through (see `assertPathWritable`), so it does not limit other agents.
1533
1787
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
1534
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path);
1788
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
1535
1789
  const mode = modeOf(a);
1536
1790
  const fs = await ctx.getFilesystem(a.branch);
1537
1791
  await assertNotBinaryOverwrite(readers, a.path, fs);
@@ -1564,17 +1818,16 @@ changeGate) {
1564
1818
  });
1565
1819
  mount({
1566
1820
  name: 'write_files',
1821
+ gated: true,
1567
1822
  description: 'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
1568
1823
  'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`, and the files it ' +
1569
1824
  'writes are committed + pushed together as you. Prefer this over many write_file ' +
1570
- 'calls. All files must be in the SAME ontology (the boundary below applies to the batch). Text files only. ' +
1825
+ 'calls. Text files only. ' +
1571
1826
  'Returns `{ count, files }`: one entry per REQUESTED path, in the order you gave them, each `{ path, outcome }` — ' +
1572
1827
  '`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
1573
1828
  'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
1574
1829
  'does not stop the others; read `files` to see what landed.' +
1575
- WRITE_MODE_NOTE +
1576
- IMAGE_CONVENTION_NOTE +
1577
- ONTOLOGY_BOUNDARY_NOTE,
1830
+ UPLOAD_ROUTE_NOTE,
1578
1831
  inputs: {
1579
1832
  type: 'object',
1580
1833
  properties: {
@@ -1628,15 +1881,14 @@ changeGate) {
1628
1881
  if (files.length === 0)
1629
1882
  return { count: 0, files: [] };
1630
1883
  const mode = modeOf(a);
1631
- // The POLICY gates still judge the whole batch: a restricted run or a
1632
- // cross-ontology batch is a call that should not have been made at all,
1633
- // not a per-path outcome, and the ontology gate must see every path
1634
- // before anything lands. What a single FILE is (not text) or what its
1635
- // path already holds (the mode) is decided per path, below.
1884
+ // The POLICY gate still judges the whole batch: a restricted run is a
1885
+ // call that should not have been made at all, not a per-path outcome.
1886
+ // The write hook is asked PER PATH, below, so a path it refuses is that
1887
+ // path's outcome and the rest of the batch still lands. What a single
1888
+ // FILE is (not text) or what its path already holds (the mode) is
1889
+ // decided per path too.
1636
1890
  for (const f of files)
1637
1891
  writePolicy.assertPathWritable(ctx.sessionId, f.path);
1638
- for (const f of files)
1639
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
1640
1892
  const fs = await ctx.getFilesystem(a.branch);
1641
1893
  // `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
1642
1894
  // batch as one commit. Structural cast avoids a workflow-internal import.
@@ -1657,6 +1909,19 @@ changeGate) {
1657
1909
  for (const f of files) {
1658
1910
  const entry = { path: f.path };
1659
1911
  outcomes.push(entry);
1912
+ try {
1913
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, f.path);
1914
+ }
1915
+ catch (err) {
1916
+ // A DELIBERATE refusal by the deployment's write hook is this path's
1917
+ // outcome and no more: its message is what the caller is meant to
1918
+ // read, and one refused path must not take the others down. Anything
1919
+ // else the hook throws is not a verdict — it is the gate itself
1920
+ // failing — so `refuse` rethrows it and the whole batch fails loudly,
1921
+ // exactly as it does in `write_file`.
1922
+ refuse(entry, err);
1923
+ continue;
1924
+ }
1660
1925
  try {
1661
1926
  assertNotDocumentEdit(readers, f.path);
1662
1927
  await assertNotBinaryOverwrite(readers, f.path, fs);
@@ -1724,8 +1989,9 @@ changeGate) {
1724
1989
  });
1725
1990
  mount({
1726
1991
  name: 'edit_file',
1992
+ gated: true,
1727
1993
  description: 'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
1728
- ONTOLOGY_BOUNDARY_NOTE,
1994
+ UPLOAD_ROUTE_NOTE,
1729
1995
  inputs: {
1730
1996
  type: 'object',
1731
1997
  properties: {
@@ -1749,7 +2015,7 @@ changeGate) {
1749
2015
  handler: async (a, ctx) => {
1750
2016
  assertNotDocumentEdit(readers, a.path);
1751
2017
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
1752
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path);
2018
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
1753
2019
  const fs = await ctx.getFilesystem(a.branch);
1754
2020
  const path = a.path;
1755
2021
  const oldStr = a.old_string;
@@ -1773,9 +2039,8 @@ changeGate) {
1773
2039
  });
1774
2040
  mount({
1775
2041
  name: 'delete_file',
1776
- 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`. ' +
1777
- `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.` +
1778
- ONTOLOGY_BOUNDARY_NOTE,
2042
+ gated: true,
2043
+ description: 'Delete ONE workspace file. 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`.',
1779
2044
  inputs: {
1780
2045
  type: 'object',
1781
2046
  properties: {
@@ -1794,14 +2059,14 @@ changeGate) {
1794
2059
  write: true,
1795
2060
  proposable: true,
1796
2061
  handler: async (a, ctx) => {
1797
- // A delete propagates no cross-ontology information (it removes a node, it
1798
- // doesn't carry bytes from elsewhere), so it is NOT ontology-write-gated — it
1799
- // only records the ontology it touched, like a read. The extension policy
1800
- // DOES apply though: a dashboard-only run must not delete graph `.md` nodes.
2062
+ // A delete carries no bytes from anywhere else — it removes a node — so
2063
+ // it goes to the READ hook, like a read, not the write hook. The
2064
+ // extension policy DOES apply though: a dashboard-only run must not
2065
+ // delete graph `.md` nodes.
1801
2066
  const path = a.path;
1802
2067
  const branch = a.branch;
1803
2068
  writePolicy.assertPathWritable(ctx.sessionId, path);
1804
- await recordOntologyRead(sessionOntologyGate, ctx, path);
2069
+ await notifyAgentRead(agentAccessGate, ctx, branch, path);
1805
2070
  const fs = await ctx.getFilesystem(branch);
1806
2071
  assertPlainPath(path);
1807
2072
  const root = await workspaceRoot(branch, ctx);
@@ -1827,12 +2092,14 @@ changeGate) {
1827
2092
  });
1828
2093
  mount({
1829
2094
  name: 'delete_folder',
2095
+ gated: true,
1830
2096
  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. ' +
1831
- '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. ' +
1832
- '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. ' +
1833
- '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). ' +
1834
- '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.' +
1835
- ONTOLOGY_BOUNDARY_NOTE,
2097
+ 'The dry run answers `{ path, kind: "folder", descendants, files, filesTruncated, allowed, reason? }` — `descendants` is ' +
2098
+ 'the file count, `files` names up to 100 of them — and a non-empty folder wants `confirm: true`. ' +
2099
+ 'Beyond what the shared rules refuse, a folder HOLDING a symbolic link, or any file you may not write, is refused ' +
2100
+ '(the link itself is never removed), and a path that is a FILE ' +
2101
+ 'is refused with a pointer to `delete_file`. You must be able to write the folder\'s own platform files too: they go ' +
2102
+ 'with it in that same one change, so its files are never left ungoverned part-way.',
1836
2103
  inputs: {
1837
2104
  type: 'object',
1838
2105
  properties: {
@@ -1870,7 +2137,7 @@ changeGate) {
1870
2137
  // The normaliser has already placed the path inside the repository; this
1871
2138
  // is the check that it really is in there before a folder is walked.
1872
2139
  assertInsideRepo(path, kbDirName);
1873
- await recordOntologyRead(sessionOntologyGate, ctx, path);
2140
+ await notifyAgentRead(agentAccessGate, ctx, branch, path);
1874
2141
  const fs = await ctx.getFilesystem(branch);
1875
2142
  const kind = await kindOf(fs, path);
1876
2143
  if (kind === null)
@@ -1964,7 +2231,8 @@ changeGate) {
1964
2231
  });
1965
2232
  mount({
1966
2233
  name: 'mkdir',
1967
- description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.' + ONTOLOGY_BOUNDARY_NOTE,
2234
+ gated: true,
2235
+ description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.',
1968
2236
  inputs: {
1969
2237
  type: 'object',
1970
2238
  properties: {
@@ -1984,18 +2252,22 @@ changeGate) {
1984
2252
  proposable: true,
1985
2253
  handler: async (a, ctx) => {
1986
2254
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
1987
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path);
2255
+ await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
1988
2256
  await (await ctx.getFilesystem(a.branch)).mkdir(a.path, { recursive: true });
1989
2257
  return { path: a.path, created: true };
1990
2258
  },
1991
2259
  });
1992
2260
  mount({
1993
2261
  name: 'move_file',
1994
- 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. ' +
1995
- `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. ` +
1996
- '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. ' +
1997
- '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.' +
1998
- ONTOLOGY_BOUNDARY_NOTE,
2262
+ gated: true,
2263
+ // A plain string again: what refuses a move names the guide, and that is in
2264
+ // the shared rules now, which are rebuilt from the layout where they live.
2265
+ 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. ' +
2266
+ 'The destination must not exist — a move never overwrites a file or merges into a folder. Access follows the ' +
2267
+ 'DESTINATION folder, so a move can change what you (and others) may do with the file: the dry run answers ' +
2268
+ '`{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }`, where `access` is your ' +
2269
+ 'own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the move has landed, ' +
2270
+ 'with every `access.md` inside a moved folder counted at its new place, and a move whose `accessChanges` is true wants `confirm: true`.',
1999
2271
  inputs: {
2000
2272
  type: 'object',
2001
2273
  properties: {
@@ -2016,8 +2288,8 @@ changeGate) {
2016
2288
  dest: str('Destination path (echoes the input).'),
2017
2289
  kind: str('`file` or `folder`.'),
2018
2290
  descendants: int('Files that move: 1 for a file, the file count under a folder.'),
2019
- access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and destination (`after`).' },
2020
- accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between source and destination.' },
2291
+ access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the move has landed (`after`).' },
2292
+ accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after`.' },
2021
2293
  allowed: { type: 'boolean', description: 'Whether the move may run.' },
2022
2294
  reason: str('Why it may not, when `allowed` is false.'),
2023
2295
  dryRun: { type: 'boolean', description: 'True on a dry run.' },
@@ -2035,12 +2307,12 @@ changeGate) {
2035
2307
  const src = a.src.replace(/\/+$/, '');
2036
2308
  const dest = a.dest.replace(/\/+$/, '');
2037
2309
  const branch = a.branch;
2038
- // A move CARRIES the source content into the destination — a genuine
2039
- // cross-ontology flow if the two differ — so BOTH endpoints are write-gated
2040
- // (unlike a plain delete, which moves no content). Check both BEFORE
2041
- // touching disk so a blocked endpoint can't leave the source already deleted.
2042
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, src);
2043
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, dest);
2310
+ // A move CARRIES the source content into the destination, so BOTH ends
2311
+ // go to the write hook (unlike a plain delete, which moves no content).
2312
+ // Ask about both BEFORE touching disk, so a refused end can't leave the
2313
+ // source already deleted.
2314
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, src);
2315
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, dest);
2044
2316
  const fs = await ctx.getFilesystem(branch);
2045
2317
  const root = await workspaceRoot(branch, ctx);
2046
2318
  // Before the kind check, which stats THROUGH a link: a dangling link at
@@ -2057,8 +2329,13 @@ changeGate) {
2057
2329
  writePolicy.assertPathWritable(ctx.sessionId, f);
2058
2330
  writePolicy.assertPathWritable(ctx.sessionId, dest + f.slice(src.length));
2059
2331
  }
2060
- const [before, after] = await Promise.all([accessAt(branch, ctx, src), accessAt(branch, ctx, dest)]);
2061
- const accessChanges = Object.keys(before).some((v) => before[v] !== after[v]);
2332
+ // `after` is the destination as it WILL be — with the `access.md` files
2333
+ // under `src` counted where they land. See `accessAfter`.
2334
+ const [before, after] = await Promise.all([
2335
+ accessAt(branch, ctx, src),
2336
+ accessAfter(branch, ctx, src, dest),
2337
+ ]);
2338
+ const accessChanges = verbsDiffer(before, after);
2062
2339
  // The placeholder moves with its folder, but it is never content.
2063
2340
  const descendants = srcFiles.filter((f) => !isFolderPlaceholder(f)).length;
2064
2341
  // Neither end may be the platform's own: a move neither takes a platform
@@ -2153,14 +2430,17 @@ changeGate) {
2153
2430
  });
2154
2431
  mount({
2155
2432
  name: 'copy_file',
2156
- 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.'
2157
- + ONTOLOGY_BOUNDARY_NOTE,
2433
+ gated: true,
2434
+ 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. ' +
2435
+ '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. ' +
2436
+ '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.',
2158
2437
  inputs: {
2159
2438
  type: 'object',
2160
2439
  properties: {
2161
2440
  branch: BRANCH_INPUT,
2162
2441
  src: wsPath(kbDirName, 'Source path'),
2163
2442
  dest: wsPath(kbDirName, 'Destination path — must not exist yet'),
2443
+ dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
2164
2444
  sessionId: SESSION_ID_INPUT,
2165
2445
  },
2166
2446
  required: ['branch', 'src', 'dest'],
@@ -2168,20 +2448,30 @@ changeGate) {
2168
2448
  },
2169
2449
  outputs: {
2170
2450
  type: 'object',
2171
- properties: { src: str('Source path (echoes the input).'), dest: str('Destination path (echoes the input).'), copied: { type: 'boolean', description: 'Always true on success.' } },
2451
+ properties: {
2452
+ src: str('Source path (echoes the input).'),
2453
+ dest: str('Destination path (echoes the input).'),
2454
+ kind: str('`file` or `folder` (dry run only; absent when `allowed` is false because you may not write the destination).'),
2455
+ 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).'),
2456
+ 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.' },
2457
+ accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after` (dry run only).' },
2458
+ allowed: { type: 'boolean', description: 'Whether the copy may run (dry run only).' },
2459
+ reason: str('Why it may not, when `allowed` is false.'),
2460
+ dryRun: { type: 'boolean', description: 'True on a dry run.' },
2461
+ copied: { type: 'boolean', description: 'True once the copy landed; false on a dry run.' },
2462
+ },
2172
2463
  required: ['src', 'dest', 'copied'],
2173
2464
  },
2174
2465
  write: true,
2175
2466
  proposable: true,
2176
2467
  handler: async (a, ctx) => {
2177
- // A copy CARRIES the source content into the destination — a genuine
2178
- // cross-ontology flow if the two differ — so BOTH endpoints are write-gated.
2179
- // Check both before touching disk.
2468
+ // A copy CARRIES the source content into the destination, so BOTH ends
2469
+ // go to the write hook. Ask about both before touching disk.
2180
2470
  writePolicy.assertPathWritable(ctx.sessionId, a.src);
2181
2471
  writePolicy.assertPathWritable(ctx.sessionId, a.dest);
2182
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src);
2183
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest);
2184
2472
  const branch = a.branch;
2473
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.src);
2474
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.dest);
2185
2475
  const src = a.src;
2186
2476
  const dest = a.dest;
2187
2477
  // A copy lands bytes at a name of its own, so it is refused by the same
@@ -2193,6 +2483,8 @@ changeGate) {
2193
2483
  // own containment check: a path with a `..` segment must not reach
2194
2484
  // `lstat` outside the workspace, even to be told a name is taken.
2195
2485
  assertPlainPath(dest);
2486
+ if (a.dryRun === true)
2487
+ return copyImpact(branch, ctx, src, dest);
2196
2488
  // The write verdict comes FIRST, for the reason `move_file` gives at
2197
2489
  // length: "already exists" is a fact about the destination folder, and a
2198
2490
  // caller who may not write there must not be told it. The lock gate
@@ -2232,6 +2524,12 @@ changeGate) {
2232
2524
  await asEntryExists(() => fs.copyFile(src, dest));
2233
2525
  }
2234
2526
  catch (err) {
2527
+ // The filesystem's own "that is a directory" becomes the sentence the
2528
+ // dry run predicts, instead of escaping as a 500 carrying the
2529
+ // server's absolute path.
2530
+ if (err?.name === 'IsDirectoryError') {
2531
+ throw new ToolError(folderCopyRefusal(src), 400);
2532
+ }
2235
2533
  const missing = isAbsence(err) || err.name === 'FileNotFoundError';
2236
2534
  if (missing) {
2237
2535
  throw (await kindOf(fs, src)) === null
@@ -2245,8 +2543,8 @@ changeGate) {
2245
2543
  });
2246
2544
  mount({
2247
2545
  name: 'unzip',
2248
- description: 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.' +
2249
- ONTOLOGY_BOUNDARY_NOTE,
2546
+ gated: true,
2547
+ description: 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.',
2250
2548
  inputs: {
2251
2549
  type: 'object',
2252
2550
  properties: {
@@ -2278,19 +2576,19 @@ changeGate) {
2278
2576
  write: true,
2279
2577
  handler: async (a, ctx) => {
2280
2578
  const zipPath = a.path;
2281
- // Reading the source archive pins/records the source ontology, so a session
2282
- // can't unzip from ontology A into ontology B without the A read counting.
2283
- await recordOntologyRead(sessionOntologyGate, ctx, zipPath);
2579
+ // Opening the archive is a read of the archive, so the read hook hears
2580
+ // about it before a single entry is extracted out of it.
2581
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, zipPath);
2284
2582
  // A .zip that is not there is a missing PATH, not an unreadable archive:
2285
2583
  // the service now says so (PathNotFoundError) and the helper turns it
2286
2584
  // into the same 404 every other file tool answers. Only that declared
2287
2585
  // answer maps — a failure part-way through an extraction is not the
2288
2586
  // archive going missing.
2289
2587
  return orDeclaredNotFound(() => ctx.workspaceService.unzipFile(workspaceIdForBranch(a.branch), zipPath, typeof a.destination === 'string' ? a.destination : undefined,
2290
- // Each extracted file is a write: a cross-ontology or write-blocked entry
2291
- // is skipped (not extracted), so an archive can't bypass the boundary — the
2292
- // extension policy applies per entry too, so a restricted run can't unzip a
2293
- // `.md` into the graph.
2588
+ // Each extracted file is a write of its own: an entry the write
2589
+ // hook refuses is skipped (not extracted), so an archive can't be
2590
+ // a way around it — the extension policy applies per entry too, so
2591
+ // a restricted run can't unzip a `.md` into the graph.
2294
2592
  (wsRelPath) => {
2295
2593
  // An entry that would land beside the repository is skipped with the
2296
2594
  // corrected-path reason, like any other refused entry.
@@ -2301,15 +2599,309 @@ changeGate) {
2301
2599
  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);
2302
2600
  }
2303
2601
  writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
2304
- return assertOntologyWriteAllowed(sessionOntologyGate, ctx, wsRelPath);
2602
+ return assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, wsRelPath);
2305
2603
  }), 'Nothing to extract');
2306
2604
  },
2307
2605
  });
2606
+ // ── uploads (bytes that never pass through the model) ───────────────────
2607
+ //
2608
+ // The pair exists because MCP tool arguments are JSON. Every byte an agent
2609
+ // sends through `write_file` is first typed out by the model, which
2610
+ // truncates long files, mangles backslash and `\u` escapes, and cannot carry
2611
+ // a PNG at all. `request_file_upload` answers an address; the agent POSTs
2612
+ // the file (or one zip holding many) there with any HTTP client;
2613
+ // `apply_file_upload` lands it on a branch in one commit. The bytes go from
2614
+ // the agent's disk to the server's and never enter a prompt.
2615
+ //
2616
+ /**
2617
+ * Why one of an upload's paths may not be landed, judged on the path ALONE —
2618
+ * or undefined when nothing about the name itself refuses it.
2619
+ *
2620
+ * The platform files are the whole of it. `access.md` governs who may read
2621
+ * and write the folder it sits in, `roles.yaml` says which roles exist, and
2622
+ * the agent guide is read as instructions: each is configuration the platform
2623
+ * obeys, and each has a write path that CHECKS the change (the roles gate
2624
+ * refuses an edit that would lock every admin out; a folder's access rules
2625
+ * are judged against who is asking). Bytes arriving by upload meet none of
2626
+ * those gates — they are a buffer the sender chose — so an upload never
2627
+ * lands one, whatever else the caller may write. `unzip` has refused
2628
+ * `roles.yaml` from an archive for the same reason; this is that rule, over
2629
+ * all four names.
2630
+ */
2631
+ const platformFileReason = (wsPath) => {
2632
+ const rel = toKbRelative(wsPath, kbDirName);
2633
+ return rel !== null && isPlatformFile(rel, kb.layout) ? platformFileUploadRefusal(rel) : undefined;
2634
+ };
2635
+ /**
2636
+ * `apply_file_upload`'s handler: resolve the stored bytes into one path per
2637
+ * file, judge each path the way `write_files` judges its own, and land the
2638
+ * survivors as ONE commit.
2639
+ *
2640
+ * The judging is deliberately the same shape as `write_files`, down to the
2641
+ * second verdict under the lock, because the promise the ticket makes is
2642
+ * that an upload is judged "exactly as `write_file` would judge it". Three
2643
+ * gates run per path and a path that fails one is that path's outcome and no
2644
+ * more: the deployment's write hook, the platform-file rule above, and the
2645
+ * `mode`. What is judged ONCE for the whole call is the destination — a
2646
+ * caller who may not write the folder at all gets one refusal naming the
2647
+ * change-request route, rather than the same refusal repeated per entry.
2648
+ */
2649
+ const applyFileUpload = async (a, ctx, uploads) => {
2650
+ const branch = a.branch;
2651
+ const token = a.token;
2652
+ if (typeof token !== 'string' || token === '') {
2653
+ throw new ToolError('Name the `token` `request_file_upload` answered with, after POSTing the file to its `uploadUrl`.', 400, { code: 'token-required' });
2654
+ }
2655
+ const mode = modeOf(a);
2656
+ const destination = a.destination.replace(/\/+$/, '');
2657
+ assertInsideRepo(destination, kbDirName);
2658
+ // CLAIMED, not consumed: an apply refused whole (a protected destination,
2659
+ // an archive that will not open) leaves the token alive so the caller can
2660
+ // retry somewhere else rather than send the bytes again. The claim is what
2661
+ // keeps it single-use meanwhile — a second apply finds the token in use.
2662
+ const upload = uploads.claim(token, ctx.user.id);
2663
+ let spent = false;
2664
+ try {
2665
+ const fs = await ctx.getFilesystem(branch);
2666
+ const root = await workspaceRoot(branch, ctx);
2667
+ // The destination, once, for the whole call. On a protected branch a
2668
+ // caller who may not write the folder gets the lock gate's own refusal —
2669
+ // which `rethrowAsWriteDenial` turns into `write-denied` with the
2670
+ // change-request steps — and nothing lands.
2671
+ const blockedDest = await writeBlocked(branch, ctx, [destination]);
2672
+ if (blockedDest.length > 0)
2673
+ throw await writeRefusal(branch, blockedDest[0], 'dir');
2674
+ if ((await kindOf(fs, destination)) === 'file') {
2675
+ throw new ToolError(`"${displayPath(destination)}" is a file, not a folder — \`destination\` names the folder the upload lands in.`, 409, { code: 'not_a_folder' });
2676
+ }
2677
+ const planned = await planUpload(upload, destination, kbDirName);
2678
+ const paths = planned.filter((p) => p.content !== undefined).map((p) => p.path);
2679
+ // One batched access read for every path, like `write_files` — empty on
2680
+ // a draft branch, where changes reach a protected branch only through a
2681
+ // change request.
2682
+ const blocked = new Set(await writeBlocked(branch, ctx, paths));
2683
+ const writes = [];
2684
+ const outcomes = [];
2685
+ /** The `files` entry for `writes[i]`, so the under-lock verdict can revise it. */
2686
+ const entryOf = [];
2687
+ for (const item of planned) {
2688
+ const entry = { path: item.path };
2689
+ outcomes.push(entry);
2690
+ if (item.content === undefined) {
2691
+ entry.outcome = 'refused';
2692
+ entry.error = item.error;
2693
+ entry.message = item.message;
2694
+ continue;
2695
+ }
2696
+ const wsPathOf = item.path;
2697
+ try {
2698
+ if (blocked.has(wsPathOf))
2699
+ throw await writeRefusal(branch, wsPathOf);
2700
+ const platform = platformFileReason(wsPathOf);
2701
+ if (platform !== undefined)
2702
+ throw new ToolError(platform, 422, { code: 'platform_file' });
2703
+ // The git folder is never a workspace path, in any spelling. A ZIP
2704
+ // entry's name has already met this rule in `zipEntryNameRefusal`; a
2705
+ // SINGLE uploaded file's has not — `.git` is a name the upload
2706
+ // route's `validateFilename` accepts — and the preflight that reads
2707
+ // the caller's own arguments never sees it either, because the name
2708
+ // came from the upload, not from the call. Asked here so that path
2709
+ // is REFUSED like any other, with the rest of the upload landing,
2710
+ // rather than failing the whole apply from inside `writeFiles`.
2711
+ assertNoGitInternalsSegment(wsPathOf);
2712
+ assertRepoRootNameFree(wsPathOf, kbDirName);
2713
+ // A link already on disk under the destination must not redirect
2714
+ // these bytes — the rule `unzip` applies per entry, applied here on
2715
+ // the path the write will take.
2716
+ const link = await symlinkOnPath(root, wsPathOf);
2717
+ if (link !== undefined) {
2718
+ throw new ToolError(`"${wsPathOf}" goes through the symbolic link "${link}"; an upload never follows links.`, 400, { code: 'symlink' });
2719
+ }
2720
+ writePolicy.assertPathWritable(ctx.sessionId, wsPathOf);
2721
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch, wsPathOf);
2722
+ // An earlier entry of this same upload counts as existing, as it
2723
+ // does in `write_files`: two `create` entries for one path are a
2724
+ // mistake the commit would otherwise hide.
2725
+ const exists = writes.some((w) => w.path === wsPathOf) || (await kindOf(fs, wsPathOf)) !== null;
2726
+ entry.outcome = decideWrite(mode, wsPathOf, exists);
2727
+ writes.push({ path: wsPathOf, content: item.content });
2728
+ entryOf.push(entry);
2729
+ }
2730
+ catch (err) {
2731
+ refuseEntry(entry, err);
2732
+ }
2733
+ }
2734
+ // The mode gate again, with every path's lock held — the verdict the
2735
+ // answer carries, for the reason `write_file` states at length. A path
2736
+ // whose verdict changed under the lock is dropped from the batch and
2737
+ // reported refused, leaving the rest to land.
2738
+ const recheck = async (pending) => {
2739
+ const kept = [];
2740
+ for (let i = 0; i < pending.length; i++) {
2741
+ const entry = entryOf[i];
2742
+ try {
2743
+ const exists = kept.some((k) => k.path === pending[i].path) || (await kindOf(fs, pending[i].path)) !== null;
2744
+ entry.outcome = decideWrite(mode, pending[i].path, exists);
2745
+ kept.push(pending[i]);
2746
+ }
2747
+ catch (err) {
2748
+ refuseEntry(entry, err);
2749
+ }
2750
+ }
2751
+ return kept;
2752
+ };
2753
+ if (writes.length > 0) {
2754
+ // `write: true` guarantees a LockingFilesystem here; `writeFiles` lands
2755
+ // the whole set as ONE commit and takes a Buffer as content, so bytes
2756
+ // reach disk exactly as they were sent — no text decode anywhere on
2757
+ // the way, which is what makes a PNG and a backslash-heavy page land
2758
+ // with the checksum they were uploaded with.
2759
+ const batching = fs;
2760
+ // In the DESTINATION folder's TURN, which `delete_folder` takes over the
2761
+ // same subtree (and `keepFolderOf` with it). `writeFiles` creates the
2762
+ // destination, and any folder above a zip entry on the way to it, as
2763
+ // part of landing the batch — and a folder delete running between that
2764
+ // creation and the commit enumerates the folder's files BEFORE these
2765
+ // exist and then removes the folder they are landing in, which is an
2766
+ // answer saying `created` for bytes that are already gone. The turn is
2767
+ // taken OUTSIDE `writeFiles`, so it is held across the under-lock
2768
+ // recheck and the commit both, and in the same order the delete takes
2769
+ // its own (the folder's turn first, then each path's lock), which is
2770
+ // what keeps two callers from waiting on each other's half.
2771
+ await ctx.workspaceService.withFolderTurn(workspaceIdForBranch(branch), destination, async () => {
2772
+ await batching.writeFiles(writes, `Apply upload of ${writes.length} file(s)`, [], recheck);
2773
+ });
2774
+ }
2775
+ // The token is spent once an ANSWER exists, even an answer in which
2776
+ // every path was refused: the apply ran and said what happened at each
2777
+ // path, and re-running it would say the same. Only a refusal that landed
2778
+ // nothing AND answered nothing (thrown above) gives the token back.
2779
+ spent = true;
2780
+ await uploads.consume(token);
2781
+ const listed = a.all === true ? outcomes : outcomes.slice(0, APPLY_ANSWER_CAP);
2782
+ return {
2783
+ destination,
2784
+ count: outcomes.filter((o) => o.outcome !== 'refused').length,
2785
+ total: outcomes.length,
2786
+ files: listed,
2787
+ ...(listed.length < outcomes.length ? { truncated: true } : {}),
2788
+ };
2789
+ }
2790
+ finally {
2791
+ if (!spent)
2792
+ uploads.release(token);
2793
+ }
2794
+ };
2795
+ // Mounted only when the composition supplied a store — see the `uploads`
2796
+ // parameter. Core always does.
2797
+ if (uploads) {
2798
+ mount({
2799
+ name: 'request_file_upload',
2800
+ fileTool: false,
2801
+ description:
2802
+ // Within the description cap (`tool-registry/description-length.ts`):
2803
+ // why a file goes this way is one of the shared rules, and the header
2804
+ // spelling of the token is on the `uploadUrl` output, where the
2805
+ // address it changes is.
2806
+ 'Ask for a one-time address to send FILE BYTES to, so their content never passes through this conversation. ' +
2807
+ 'Use it for anything `write_file` cannot carry faithfully: a large file, a file full of backslashes or `\\u` ' +
2808
+ 'escapes, a binary file (a PNG, a PDF, a zip), or many files at once (zip them). ' +
2809
+ 'Returns `{ uploadUrl, token, expiresAt, expiresInSeconds, maxBytes }`. THEN: ' +
2810
+ '(1) POST the file as the raw request body to `uploadUrl` with `?filename=<name>` — ' +
2811
+ '`curl -X POST --data-binary @skill.zip "<uploadUrl>?filename=skill.zip"` — which answers what it received; ' +
2812
+ '(2) call `apply_file_upload` with the same `token`, a `branch` and a destination folder. ' +
2813
+ 'One token carries one file or one zip, is bound to you and expires at `expiresAt`: an upload nobody applies ' +
2814
+ 'by then is deleted, and one over `maxBytes` is refused when you send it, naming the limit.',
2815
+ inputs: { type: 'object', properties: {}, additionalProperties: false },
2816
+ outputs: {
2817
+ type: 'object',
2818
+ properties: {
2819
+ uploadUrl: str('The absolute URL to POST the bytes to. Carries the token; add `?filename=<name>`. To keep the token out ' +
2820
+ 'of a URL — when the command line you send from is logged or shared — POST to this address without its ' +
2821
+ 'last (token) segment and send the token in an `x-upload-token` header instead.'),
2822
+ token: str('The token itself — what `apply_file_upload` takes, and what an `x-upload-token` header carries when you ' +
2823
+ 'would rather it not sit in a URL. Treat it as a credential.'),
2824
+ expiresAt: str('ISO-8601 instant after which the token, and any bytes sent with it, are gone.'),
2825
+ expiresInSeconds: int('Seconds from now until `expiresAt`.'),
2826
+ maxBytes: int('The largest upload this deployment accepts, in bytes.'),
2827
+ },
2828
+ required: ['uploadUrl', 'token', 'expiresAt', 'expiresInSeconds', 'maxBytes'],
2829
+ },
2830
+ // A read-scoped caller has nothing to do with an upload token: the only
2831
+ // thing it unlocks is a write. Refused at the handler factory, by scope,
2832
+ // before the token is minted.
2833
+ write: true,
2834
+ handler: async (_a, ctx) => uploads.issue(ctx.user),
2835
+ });
2836
+ mount({
2837
+ name: 'apply_file_upload',
2838
+ gated: true,
2839
+ description: 'Land a file you have already uploaded (see `request_file_upload`) in a folder on a branch, in ONE commit, as you. ' +
2840
+ 'A single file lands under the name it was sent with; a zip lands as its entries, keeping their folder structure. ' +
2841
+ 'Returns `{ destination, count, total, files }`: one entry per path, each `{ path, outcome }` — `created` / ' +
2842
+ '`replaced` / `updated`, or `refused` with `error` (the code) and `message` (why). `count` is how many landed and ' +
2843
+ '`total` how many paths there were; `files` is cut to the first 25 unless you pass `all: true`. ' +
2844
+ 'Every path is judged one by one — by your write access, the platform-file rules and what is already there — ' +
2845
+ 'exactly as `write_file` judges it, and a refused path does not stop the others. ' +
2846
+ 'The token is single-use: it is spent by the apply that lands it, and refused if you use it twice, let it ' +
2847
+ 'expire, or present one issued to somebody else. `mode` means what it means on `write_file`.',
2848
+ inputs: {
2849
+ type: 'object',
2850
+ properties: {
2851
+ branch: BRANCH_INPUT,
2852
+ token: str('The `token` from `request_file_upload`, after you have POSTed the file to its `uploadUrl`.'),
2853
+ destination: wsPath(kbDirName, 'Folder the upload lands in (created if it is not there yet)'),
2854
+ mode: WRITE_MODE_INPUT,
2855
+ all: {
2856
+ type: 'boolean',
2857
+ description: 'List EVERY path in `files` instead of the first 25. `total` always says how many there were, so ask for ' +
2858
+ 'all only when you need to read each outcome.',
2859
+ },
2860
+ sessionId: SESSION_ID_INPUT,
2861
+ },
2862
+ required: ['branch', 'token', 'destination'],
2863
+ additionalProperties: false,
2864
+ },
2865
+ outputs: {
2866
+ type: 'object',
2867
+ properties: {
2868
+ destination: str('The folder the upload was applied to (echoes the input).'),
2869
+ count: int('How many paths landed — the entries in `files` whose `outcome` is not `refused`.'),
2870
+ total: int('How many paths the upload held, whether or not `files` lists them all.'),
2871
+ files: {
2872
+ type: 'array',
2873
+ description: 'One entry per path, in the order the upload held them. Cut to 25 unless `all` was true.',
2874
+ items: {
2875
+ type: 'object',
2876
+ properties: {
2877
+ path: str('The workspace path this entry was judged at.'),
2878
+ outcome: {
2879
+ type: 'string',
2880
+ enum: ['created', 'replaced', 'updated', 'refused'],
2881
+ description: 'What happened at this path. `refused` means nothing was written there.',
2882
+ },
2883
+ error: str('Present when `outcome` is `refused`: the refusal code — e.g. `exists`, `missing`, `invalid_entry`, `platform_file`, `write-denied`.'),
2884
+ message: str('Present when `outcome` is `refused`: the full refusal, the same one `write_file` would have given.'),
2885
+ },
2886
+ required: ['path', 'outcome'],
2887
+ },
2888
+ },
2889
+ truncated: { type: 'boolean', description: 'True when `files` was cut: `total` is larger than what it lists. Pass `all: true` for the rest.' },
2890
+ },
2891
+ required: ['destination', 'count', 'total', 'files'],
2892
+ },
2893
+ write: true,
2894
+ // So a protected-branch refusal arrives as `write-denied`, with the
2895
+ // change-request steps, exactly as it does from write_file.
2896
+ proposable: true,
2897
+ handler: async (a, ctx) => applyFileUpload(a, ctx, uploads),
2898
+ });
2899
+ }
2308
2900
  // ── shell (internal-only) ───────────────────────────────────────────────
2309
2901
  mount({
2310
2902
  name: 'execute_command',
2311
- 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.' +
2312
- ONTOLOGY_BOUNDARY_NOTE,
2903
+ gated: true,
2904
+ 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.',
2313
2905
  internalOnly: true,
2314
2906
  fileTool: false,
2315
2907
  // The one tool the mount's branch check skips: the handler below resolves an
@@ -2397,11 +2989,12 @@ changeGate) {
2397
2989
  throw new ToolError(`execute_command got an invalid \`branch\`: ${err.message} — ` +
2398
2990
  'pass the exact branch (draft) whose workspace to run the command in.', 400);
2399
2991
  }
2400
- // Shell is a write path with no single target path to check, so enforce the
2401
- // boundary at the session level: refuse once the run is already write-blocked,
2402
- // or when the run is restricted to a file type (shell could write anything).
2992
+ // Shell is a write path with no single target path to check, so the
2993
+ // write hook is asked once for the call itself, with no path — and the
2994
+ // run must not be restricted to a file type either, since shell could
2995
+ // write anything.
2403
2996
  writePolicy.assertUnrestricted(ctx.sessionId);
2404
- await assertShellAllowedWithinOntology(sessionOntologyGate, ctx);
2997
+ await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
2405
2998
  // Canonical per-branch bootstrap entry point — it owns the workspace-id
2406
2999
  // encoding and the single-flight clone, so the shell never derives a
2407
3000
  // workspace path by hand.