@bevel-software/platform-core-backend 0.23.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 (227) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +17 -3
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +25 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core/lifecycle.d.ts +12 -0
  9. package/dist/core/lifecycle.d.ts.map +1 -1
  10. package/dist/core/lifecycle.js +10 -0
  11. package/dist/core/lifecycle.js.map +1 -1
  12. package/dist/core-config.d.ts +16 -0
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +17 -0
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +10 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/modules/access/access.routes.d.ts.map +1 -1
  21. package/dist/modules/access/access.routes.js +4 -1
  22. package/dist/modules/access/access.routes.js.map +1 -1
  23. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  24. package/dist/modules/access/directory-sync-bot.js +7 -3
  25. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  26. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  27. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  28. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  29. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  30. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  31. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  32. package/dist/modules/agent-instructions/compose.js +39 -6
  33. package/dist/modules/agent-instructions/compose.js.map +1 -1
  34. package/dist/modules/agent-instructions/index.d.ts +2 -1
  35. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  36. package/dist/modules/agent-instructions/index.js +2 -1
  37. package/dist/modules/agent-instructions/index.js.map +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  39. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  40. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  41. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  42. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  43. package/dist/modules/audit/agent-audit.service.js +4 -2
  44. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  45. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  46. package/dist/modules/auth/account-erasure.service.js +57 -16
  47. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  49. package/dist/modules/auth/auth.service.js +25 -15
  50. package/dist/modules/auth/auth.service.js.map +1 -1
  51. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  52. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  53. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  54. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  55. package/dist/modules/database/connection.d.ts +16 -0
  56. package/dist/modules/database/connection.d.ts.map +1 -1
  57. package/dist/modules/database/connection.js +117 -0
  58. package/dist/modules/database/connection.js.map +1 -1
  59. package/dist/modules/database/core-schema.d.ts +296 -96
  60. package/dist/modules/database/core-schema.d.ts.map +1 -1
  61. package/dist/modules/database/core-schema.js +81 -31
  62. package/dist/modules/database/core-schema.js.map +1 -1
  63. package/dist/modules/database/migrate.d.ts +8 -0
  64. package/dist/modules/database/migrate.d.ts.map +1 -1
  65. package/dist/modules/database/migrate.js +271 -1
  66. package/dist/modules/database/migrate.js.map +1 -1
  67. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  68. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  69. package/dist/modules/mcp/mcp.service.js +38 -8
  70. package/dist/modules/mcp/mcp.service.js.map +1 -1
  71. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  72. package/dist/modules/plugins/join-request-records.store.js +7 -4
  73. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  74. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  75. package/dist/modules/tool-auth/external-api-key.service.js +8 -2
  76. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  77. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  78. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  79. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  80. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  81. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  82. package/dist/modules/tool-registry/description-length.js +108 -0
  83. package/dist/modules/tool-registry/description-length.js.map +1 -0
  84. package/dist/modules/workflow/git/git.service.d.ts +25 -0
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +40 -1
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  89. package/dist/modules/workflow/pending-commits.service.js +5 -1
  90. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  91. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  92. package/dist/modules/workflow/recovery-bot.js +7 -3
  93. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  94. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  95. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  97. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  98. package/dist/modules/workflow/workflow.service.js +4 -1
  99. package/dist/modules/workflow/workflow.service.js.map +1 -1
  100. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  101. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  102. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  103. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  104. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  105. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  106. package/dist/modules/workspace/agent-upload.store.js +553 -0
  107. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  108. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  109. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  110. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  111. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  112. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  113. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  114. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  115. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  116. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  117. package/dist/modules/workspace/upload-limits.js +13 -0
  118. package/dist/modules/workspace/upload-limits.js.map +1 -0
  119. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  120. package/dist/modules/workspace/workspace.routes.js +1 -1
  121. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  122. package/dist/modules/workspace/workspace.service.d.ts +13 -0
  123. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  124. package/dist/modules/workspace/workspace.service.js +61 -33
  125. package/dist/modules/workspace/workspace.service.js.map +1 -1
  126. package/dist/modules/workspace/workspace.tools.d.ts +11 -9
  127. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  128. package/dist/modules/workspace/workspace.tools.js +512 -113
  129. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  130. package/dist/modules/workspace/write-denial.d.ts +0 -6
  131. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  132. package/dist/modules/workspace/write-denial.js +0 -6
  133. package/dist/modules/workspace/write-denial.js.map +1 -1
  134. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  135. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  136. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  137. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  138. package/dist/shared/column-crypto.d.ts +194 -0
  139. package/dist/shared/column-crypto.d.ts.map +1 -0
  140. package/dist/shared/column-crypto.js +144 -0
  141. package/dist/shared/column-crypto.js.map +1 -0
  142. package/dist/shared/token-crypto.d.ts.map +1 -1
  143. package/dist/shared/token-crypto.js +25 -1
  144. package/dist/shared/token-crypto.js.map +1 -1
  145. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  146. package/dist/tenancy/static-tenant-source.js +1 -0
  147. package/dist/tenancy/static-tenant-source.js.map +1 -1
  148. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  149. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  150. package/dist/tenancy/tenant-secrets.js +4 -0
  151. package/dist/tenancy/tenant-secrets.js.map +1 -1
  152. package/kb-template/AGENTS.md +2 -0
  153. package/migrations/0016_pii_encryption.sql +20 -0
  154. package/migrations/meta/0016_snapshot.json +2327 -0
  155. package/migrations/meta/_journal.json +7 -0
  156. package/package.json +3 -3
  157. package/src/core/__tests__/lifecycle.test.ts +71 -4
  158. package/src/core/create-core-server.ts +21 -3
  159. package/src/core/create-core-services.ts +27 -1
  160. package/src/core/lifecycle.ts +19 -0
  161. package/src/core-config.ts +18 -0
  162. package/src/index.ts +18 -0
  163. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  164. package/src/modules/access/access.routes.ts +4 -1
  165. package/src/modules/access/directory-sync-bot.ts +7 -3
  166. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
  167. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  168. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  169. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  170. package/src/modules/agent-instructions/compose.ts +50 -7
  171. package/src/modules/agent-instructions/index.ts +12 -0
  172. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  173. package/src/modules/audit/agent-audit.service.ts +4 -2
  174. package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
  175. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  176. package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
  177. package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
  178. package/src/modules/auth/account-erasure.service.ts +69 -18
  179. package/src/modules/auth/auth.service.ts +33 -23
  180. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  181. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  182. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  183. package/src/modules/database/__tests__/connection.test.ts +12 -0
  184. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  185. package/src/modules/database/connection.ts +117 -0
  186. package/src/modules/database/core-schema.ts +81 -31
  187. package/src/modules/database/migrate.ts +353 -1
  188. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  189. package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
  190. package/src/modules/mcp/mcp.service.ts +46 -7
  191. package/src/modules/plugins/join-request-records.store.ts +7 -4
  192. package/src/modules/tool-auth/external-api-key.service.ts +8 -2
  193. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  194. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  195. package/src/modules/tool-registry/description-length.ts +111 -0
  196. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  197. package/src/modules/workflow/git/git.service.ts +47 -1
  198. package/src/modules/workflow/pending-commits.service.ts +5 -1
  199. package/src/modules/workflow/recovery-bot.ts +7 -3
  200. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  201. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  202. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  203. package/src/modules/workflow/workflow.service.ts +4 -1
  204. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  205. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
  206. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  207. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
  208. package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
  209. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
  210. package/src/modules/workspace/__tests__/workspace.tools.test.ts +84 -45
  211. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  212. package/src/modules/workspace/agent-upload.store.ts +668 -0
  213. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  214. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  215. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  216. package/src/modules/workspace/upload-limits.ts +12 -0
  217. package/src/modules/workspace/workspace.routes.ts +1 -2
  218. package/src/modules/workspace/workspace.service.ts +63 -37
  219. package/src/modules/workspace/workspace.tools.ts +588 -127
  220. package/src/modules/workspace/write-denial.ts +0 -8
  221. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  222. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  223. package/src/shared/column-crypto.ts +218 -0
  224. package/src/shared/token-crypto.ts +28 -1
  225. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
  226. package/src/tenancy/static-tenant-source.ts +1 -0
  227. package/src/tenancy/tenant-secrets.ts +5 -1
@@ -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
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,30 +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
- /**
323
- * What an agent needs to know about escape sequences in the content it sends,
324
- * on the three tools that take content as a JSON string.
325
- *
326
- * The three write routes — the MCP endpoint, the `/api/agent/tools/<name>`
327
- * route and `call_tool_chain` — were measured end to end against raw requests
328
- * and a byte-level read of the stored file (see
329
- * `__tests__/escape-sequences.routes.test.ts`): each stores content exactly as
330
- * the JSON string value decodes ONCE. So when an escape arrives already
331
- * decoded, the decoding happened in the client that built the request, and no
332
- * tool here can tell that content from content that was meant to be decoded.
333
- * Hence a warning rather than a fix, and the pointer to the one route whose
334
- * payload is bytes rather than a JSON string.
335
- */
336
- const ESCAPE_SEQUENCE_NOTE = ' Escape sequences: some clients decode them in arguments before sending, so content meant to CONTAIN an escape rather ' +
337
- 'than what it stands for (the six characters backslash, `u`, `0`, `0`, `4`, `1`, say, rather than the letter `A`) can ' +
338
- 'reach this tool already decoded — what arrives is stored byte for byte, so when that distinction matters, verify what ' +
339
- 'landed (`read_file`, or a hash) and send such content through the upload route (`request_upload_token` + `apply_upload` ' +
340
- 'where offered, otherwise Upload in the app), which lands it unchanged.';
341
285
  /** The refusal `create` gives on a path that already holds something. */
342
286
  function pathExists(path) {
343
287
  return new ToolError(`"${displayPath(path)}" already exists — pass mode: overwrite to replace it, or write to a different path.`, 409, { code: 'exists', path });
@@ -361,6 +305,144 @@ function decideWrite(mode, path, exists) {
361
305
  return 'updated';
362
306
  return exists ? 'replaced' : 'created';
363
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
+ }
364
446
  /** The three modes, as a set the handler can check a raw argument against. */
365
447
  const WRITE_MODES = ['create', 'overwrite', 'update'];
366
448
  /**
@@ -541,7 +623,16 @@ skillSaveCheck,
541
623
  * would be made on. Optional so tool harnesses need not wire it; the read
542
624
  * verdict alone then decides, which differs only at a root.
543
625
  */
544
- 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) {
545
636
  const { kbDirName } = kb;
546
637
  /**
547
638
  * The one extension→reader registry every read-shaped decision routes
@@ -1083,10 +1174,14 @@ changeGate) {
1083
1174
  const linkRefusal = (path) => `"${path}" is a symbolic link; the agent tools never follow or remove links.`;
1084
1175
  const mount = (spec) => {
1085
1176
  const path = `/api/agent/tools/${spec.name}`;
1086
- // Every workspace entrypoint carries the agent-guide reminder, every file
1087
- // tool the one content rule, and every tool a permission can refuse the
1088
- // proposal route — appended once here so no tool (especially the
1089
- // 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.
1090
1185
  // Whether a call to this tool MUST name a branch, read off the tool's own
1091
1186
  // declaration rather than assumed of the family. Every tool mounted here
1092
1187
  // requires `branch` today; keying on the schema means a tool that declares
@@ -1094,10 +1189,8 @@ changeGate) {
1094
1189
  // own route) is not handed a refusal it never asked for.
1095
1190
  const requiresBranch = (spec.inputs.required ?? []).includes('branch');
1096
1191
  const describe = () => (typeof spec.description === 'function' ? spec.description() : spec.description) +
1097
- (spec.proposable ? PROPOSAL_ROUTE_NOTE : '') +
1098
- (spec.fileTool === false ? '' : CONTENT_RULE) +
1099
- kbConventionsNote(kb.layout) +
1100
- (spec.gated ? agentAccessGate.notes.gatedToolNote() : '');
1192
+ (spec.gated ? agentAccessGate.notes.gatedToolNote() : '') +
1193
+ sharedRulesPointer(kb.layout);
1101
1194
  const def = toolDef({
1102
1195
  name: spec.name,
1103
1196
  description: describe(),
@@ -1246,7 +1339,13 @@ changeGate) {
1246
1339
  mount({
1247
1340
  name: 'read_file',
1248
1341
  gated: true,
1249
- description: 'Read a workspace file as text. Returns `{ path, content }`. Images (.png/.jpg/.jpeg/.gif/.webp) return the IMAGE ITSELF as native MCP image content (plus a one-line text note naming the file), so you can look at the picture — up to 3.5 MB of raw image data; a larger image gets an honest refusal asking for a locally downscaled copy or a smaller export (`.svg` is text and reads as text). Images come back only on a DIRECT call: inside `call_tool_chain` an image read yields an `{ image_omitted, note }` stub instead. Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods) and PDFs return their EXTRACTED text under an honest `[extracted text of …]` header, with `[slide N]`/`[sheet: Name]`/`[page N]` markers — the extraction is READ-ONLY (layout/images omitted; such files cannot be edited as text, only replaced by uploading a new version). Email files (.eml/.msg) return their EXTRACTED text the same way: a `[from]`/`[to]`/`[subject]`/`[date]` header block, the body (plain-text part preferred; an HTML-only body is stripped to text), and an `[attachments]` name list — attachments are listed, never extracted. Other binary files return a one-line description instead of raw bytes. Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. A spill ref is workspace-independent: `branch` is ignored for it.',
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.',
1250
1349
  inputs: {
1251
1350
  type: 'object',
1252
1351
  properties: {
@@ -1350,12 +1449,17 @@ changeGate) {
1350
1449
  mount({
1351
1450
  name: 'file_stat',
1352
1451
  gated: true,
1353
- description: () => 'Get a file/directory\'s metadata (name, type, size, …) without returning content. A file also reports `contentMode`: `text` (read, write and edit it as text), `document` (read returns an extraction; replace it by upload) or `binary` (bytes: copy, move, delete, or replace by upload), plus `kind` (`text` | `document` | `image` | `binary`), `mime`, `mimeSource` and `textEditable` — decided by the same file readers read_file, grep and the write tools use, so an extensionless text file is `text/plain`.' +
1354
- ' Every entry also reports what you may DO with it. ' +
1355
- `\`managed\` is true for a platform item — a platform file (${platformFileList(kb.layout)}) or a platform folder (the repository root or a reserved root folder such as \`KnowledgeBase/\`); managed items are never movable or deletable through these tools. ` +
1356
- '`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to learn why, and who else holds each verb. `movable` and `deletable` say whether `move_file` / `delete_file` / `delete_folder` would be allowed for you, judged like their dry runs: not managed, no symbolic link, and on a protected branch you hold write on the item AND on every file under a folder (on a draft branch writes are not gated). `movable` judges the source side only; the destination is judged by a `move_file` dry run. ' +
1357
- 'For a folder, `descendants` is the number of files under it at any depth; counting stops at 10000 and `descendantsTruncated` says so, and past that point `movable` and `deletable` are false because a folder that large was not judged in full — run the `move_file` or `delete_folder` dry run for the real verdict. ' +
1358
- 'Call this before a move or delete to see what it would touch.',
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.',
1359
1463
  inputs: {
1360
1464
  type: 'object',
1361
1465
  properties: {
@@ -1645,9 +1749,7 @@ changeGate) {
1645
1749
  gated: true,
1646
1750
  description: 'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
1647
1751
  '`created`, `replaced` or `updated`.' +
1648
- WRITE_MODE_NOTE +
1649
- IMAGE_CONVENTION_NOTE +
1650
- ESCAPE_SEQUENCE_NOTE,
1752
+ UPLOAD_ROUTE_NOTE,
1651
1753
  inputs: {
1652
1754
  type: 'object',
1653
1755
  properties: {
@@ -1725,9 +1827,7 @@ changeGate) {
1725
1827
  '`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
1726
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 — ' +
1727
1829
  'does not stop the others; read `files` to see what landed.' +
1728
- WRITE_MODE_NOTE +
1729
- IMAGE_CONVENTION_NOTE +
1730
- ESCAPE_SEQUENCE_NOTE,
1830
+ UPLOAD_ROUTE_NOTE,
1731
1831
  inputs: {
1732
1832
  type: 'object',
1733
1833
  properties: {
@@ -1891,7 +1991,7 @@ changeGate) {
1891
1991
  name: 'edit_file',
1892
1992
  gated: true,
1893
1993
  description: 'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
1894
- ESCAPE_SEQUENCE_NOTE,
1994
+ UPLOAD_ROUTE_NOTE,
1895
1995
  inputs: {
1896
1996
  type: 'object',
1897
1997
  properties: {
@@ -1940,8 +2040,7 @@ changeGate) {
1940
2040
  mount({
1941
2041
  name: 'delete_file',
1942
2042
  gated: true,
1943
- description: () => 'Delete ONE workspace file (a symbolic link is refused: links are never followed or removed). Committed + pushed as you. Its folder stays, even when this was its last file. Files only: a folder is refused with a pointer to `delete_folder`. ' +
1944
- `A platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) and git metadata are refused.`,
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`.',
1945
2044
  inputs: {
1946
2045
  type: 'object',
1947
2046
  properties: {
@@ -1995,10 +2094,12 @@ changeGate) {
1995
2094
  name: 'delete_folder',
1996
2095
  gated: true,
1997
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. ' +
1998
- 'Preflight first: `dryRun: true` changes nothing and answers `{ path, kind: "folder", descendants, files, filesTruncated, allowed, reason? }` — `descendants` is the file count, `files` names up to 100 of them. ' +
1999
- 'A non-empty folder is deleted only with `confirm: true`; without it the call deletes nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm. ' +
2000
- 'Refused (in a dry run as `allowed: false` with the `reason`): a platform folder (the repository root or a reserved root folder such as `KnowledgeBase/`), git metadata, a folder holding a symbolic link (links are never removed), and a folder holding any file you may not write. A path that is a file is refused with a pointer to `delete_file`, and a path through a symbolic link is refused (links are never followed). ' +
2001
- 'The folder\'s own platform files (`access.md`, `.bevelignore`) go with it in that same one change, so its files are never left ungoverned part-way; you must be able to write those platform files too.',
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.',
2002
2103
  inputs: {
2003
2104
  type: 'object',
2004
2105
  properties: {
@@ -2159,10 +2260,14 @@ changeGate) {
2159
2260
  mount({
2160
2261
  name: 'move_file',
2161
2262
  gated: true,
2162
- description: () => 'Move or rename a workspace FILE or FOLDER; a folder moves recursively, with everything under it. `dest` is the full new path, not the folder to move into. Lands as a delete + create, committed + pushed as you. ' +
2163
- `Rules: the destination must not exist — a move never overwrites a file or merges into a folder; a platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) is refused with "<name> is a platform file and stays in its folder." — a folder that moves takes its own platform files along, still in their folder; a platform folder (the repository root or a reserved root folder such as \`KnowledgeBase/\`) and git metadata are refused; a move cannot create a platform file or folder at \`dest\` either (renaming a note to \`access.md\` is refused); a path through a symbolic link is refused, since links are never followed; on a protected branch you must be able to write both ends — for a folder, every file under it at its old and its new path. ` +
2164
- 'Access follows the destination folder. Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the move has landed, with every `access.md` inside a moved folder counted at its new place. ' +
2165
- 'A move whose `accessChanges` is true runs only with `confirm: true`; without it the call moves nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm.',
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`.',
2166
2271
  inputs: {
2167
2272
  type: 'object',
2168
2273
  properties: {
@@ -2498,6 +2603,300 @@ changeGate) {
2498
2603
  }), 'Nothing to extract');
2499
2604
  },
2500
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
+ }
2501
2900
  // ── shell (internal-only) ───────────────────────────────────────────────
2502
2901
  mount({
2503
2902
  name: 'execute_command',