@bevel-software/platform-core-backend 0.28.1 → 0.30.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 (274) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.d.ts.map +1 -1
  8. package/dist/core/catalog-revision.js +4 -2
  9. package/dist/core/catalog-revision.js.map +1 -1
  10. package/dist/core/compose-embed.d.ts +35 -0
  11. package/dist/core/compose-embed.d.ts.map +1 -0
  12. package/dist/core/compose-embed.js +14 -0
  13. package/dist/core/compose-embed.js.map +1 -0
  14. package/dist/core/core-ports.d.ts +10 -0
  15. package/dist/core/core-ports.d.ts.map +1 -1
  16. package/dist/core/core-ports.js.map +1 -1
  17. package/dist/core/create-core-server.d.ts.map +1 -1
  18. package/dist/core/create-core-server.js +8 -2
  19. package/dist/core/create-core-server.js.map +1 -1
  20. package/dist/core/create-core-services.d.ts +4 -1
  21. package/dist/core/create-core-services.d.ts.map +1 -1
  22. package/dist/core/create-core-services.js +57 -18
  23. package/dist/core/create-core-services.js.map +1 -1
  24. package/dist/core/lifecycle.d.ts +19 -6
  25. package/dist/core/lifecycle.d.ts.map +1 -1
  26. package/dist/core/lifecycle.js +17 -6
  27. package/dist/core/lifecycle.js.map +1 -1
  28. package/dist/modules/access/access-control.interface.d.ts +20 -0
  29. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  30. package/dist/modules/access/access-control.service.d.ts +68 -15
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  32. package/dist/modules/access/access-control.service.js +282 -58
  33. package/dist/modules/access/access-control.service.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +20 -6
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  39. package/dist/modules/auth/index.d.ts +15 -0
  40. package/dist/modules/auth/index.d.ts.map +1 -0
  41. package/dist/modules/auth/index.js +20 -0
  42. package/dist/modules/auth/index.js.map +1 -0
  43. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  44. package/dist/modules/diff/diff.service.js +7 -1
  45. package/dist/modules/diff/diff.service.js.map +1 -1
  46. package/dist/modules/embed/embed.interface.d.ts +22 -0
  47. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  48. package/dist/modules/embed/embed.service.d.ts +28 -14
  49. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  50. package/dist/modules/embed/embed.service.js +38 -17
  51. package/dist/modules/embed/embed.service.js.map +1 -1
  52. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  53. package/dist/modules/embed/embed.tools.js +10 -2
  54. package/dist/modules/embed/embed.tools.js.map +1 -1
  55. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  56. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  57. package/dist/modules/kb-fs/git-dir.js +58 -0
  58. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  59. package/dist/modules/kb-fs/index.d.ts +9 -0
  60. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  61. package/dist/modules/kb-fs/index.js +3 -0
  62. package/dist/modules/kb-fs/index.js.map +1 -0
  63. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  64. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  65. package/dist/modules/kb-fs/ref-files.js +249 -0
  66. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  67. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  68. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  69. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  70. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  71. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  72. package/dist/modules/plugins/teams.routes.js +9 -1
  73. package/dist/modules/plugins/teams.routes.js.map +1 -1
  74. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  75. package/dist/modules/skills/skills.tools.d.ts +8 -1
  76. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  77. package/dist/modules/skills/skills.tools.js +115 -11
  78. package/dist/modules/skills/skills.tools.js.map +1 -1
  79. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  81. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  82. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  83. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  84. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  85. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  86. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  87. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  88. package/dist/modules/tool-helpers/index.d.ts +1 -0
  89. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  90. package/dist/modules/tool-helpers/index.js +1 -0
  91. package/dist/modules/tool-helpers/index.js.map +1 -1
  92. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  93. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  94. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  95. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/file-lock.service.js +70 -18
  97. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  98. package/dist/modules/workflow/git/git.service.d.ts +42 -0
  99. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/git/git.service.js +201 -24
  101. package/dist/modules/workflow/git/git.service.js.map +1 -1
  102. package/dist/modules/workflow/index.d.ts +8 -0
  103. package/dist/modules/workflow/index.d.ts.map +1 -0
  104. package/dist/modules/workflow/index.js +8 -0
  105. package/dist/modules/workflow/index.js.map +1 -0
  106. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  107. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  108. package/dist/modules/workflow/pending-commits.service.js +4 -24
  109. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  110. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  111. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  112. package/dist/modules/workflow/workflow.routes.js +20 -1
  113. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  114. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/workflow.service.js +5 -3
  116. package/dist/modules/workflow/workflow.service.js.map +1 -1
  117. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  118. package/dist/modules/workspace/agent-access.gate.js +2 -2
  119. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  120. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  121. package/dist/modules/workspace/bounded-matcher.js +261 -0
  122. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  123. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  124. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  125. package/dist/modules/workspace/fetch-instant.js +13 -0
  126. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  127. package/dist/modules/workspace/index.d.ts +17 -0
  128. package/dist/modules/workspace/index.d.ts.map +1 -0
  129. package/dist/modules/workspace/index.js +16 -0
  130. package/dist/modules/workspace/index.js.map +1 -0
  131. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  132. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  133. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  134. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  135. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  136. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  137. package/dist/modules/workspace/kb-repository.js +811 -0
  138. package/dist/modules/workspace/kb-repository.js.map +1 -0
  139. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  140. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  141. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  142. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  143. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  144. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  145. package/dist/modules/workspace/routine-write-policy.js +0 -5
  146. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  147. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  148. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  149. package/dist/modules/workspace/set-aside-clone.js +139 -0
  150. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  151. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  152. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  153. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  154. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  155. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  156. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  157. package/dist/modules/workspace/workspace.service.js +240 -161
  158. package/dist/modules/workspace/workspace.service.js.map +1 -1
  159. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  160. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.tools.js +268 -356
  162. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  163. package/dist/shared/workspace-id.d.ts +17 -0
  164. package/dist/shared/workspace-id.d.ts.map +1 -1
  165. package/dist/shared/workspace-id.js +24 -0
  166. package/dist/shared/workspace-id.js.map +1 -1
  167. package/dist/test-setup.js +4 -0
  168. package/dist/test-setup.js.map +1 -1
  169. package/mcp-app/page.html +142 -8
  170. package/package.json +3 -3
  171. package/src/__tests__/test-repositories.ts +22 -0
  172. package/src/__tests__/workspace-service.ts +69 -0
  173. package/src/core/__tests__/compose-embed.test.ts +65 -0
  174. package/src/core/__tests__/lifecycle.test.ts +45 -26
  175. package/src/core/catalog-revision.ts +4 -2
  176. package/src/core/compose-embed.ts +50 -0
  177. package/src/core/core-ports.ts +10 -0
  178. package/src/core/create-core-server.ts +8 -2
  179. package/src/core/create-core-services.ts +63 -24
  180. package/src/core/lifecycle.ts +34 -12
  181. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
  182. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  183. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  184. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  185. package/src/modules/access/access-control.interface.ts +22 -0
  186. package/src/modules/access/access-control.service.ts +302 -58
  187. package/src/modules/access/access.routes.ts +22 -6
  188. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  189. package/src/modules/auth/index.ts +29 -0
  190. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  191. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  192. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  193. package/src/modules/diff/diff.service.ts +6 -4
  194. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  195. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  196. package/src/modules/embed/embed.interface.ts +24 -0
  197. package/src/modules/embed/embed.service.ts +44 -19
  198. package/src/modules/embed/embed.tools.ts +10 -2
  199. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  200. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  201. package/src/modules/kb-fs/git-dir.ts +53 -0
  202. package/src/modules/kb-fs/index.ts +8 -0
  203. package/src/modules/kb-fs/ref-files.ts +279 -0
  204. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  205. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  206. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  207. package/src/modules/plugins/teams.routes.ts +14 -1
  208. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  209. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  210. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  211. package/src/modules/skills/skills.contract.ts +4 -0
  212. package/src/modules/skills/skills.tools.ts +120 -10
  213. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  214. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  215. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  216. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  217. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  218. package/src/modules/tool-helpers/index.ts +5 -0
  219. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  220. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  221. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
  222. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  223. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  224. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  225. package/src/modules/workflow/file-lock.service.ts +79 -18
  226. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  227. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  228. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  229. package/src/modules/workflow/git/git.service.ts +207 -22
  230. package/src/modules/workflow/index.ts +7 -0
  231. package/src/modules/workflow/pending-commits.service.ts +4 -23
  232. package/src/modules/workflow/workflow-hooks.ts +1 -1
  233. package/src/modules/workflow/workflow.routes.ts +24 -1
  234. package/src/modules/workflow/workflow.service.ts +3 -2
  235. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  236. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  237. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  238. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  239. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  240. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  241. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  242. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  243. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  244. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  245. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  246. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  247. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  248. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  249. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  250. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  251. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  252. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  253. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  254. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  255. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  256. package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
  257. package/src/modules/workspace/agent-access.gate.ts +2 -2
  258. package/src/modules/workspace/bounded-matcher.ts +328 -0
  259. package/src/modules/workspace/fetch-instant.ts +13 -0
  260. package/src/modules/workspace/index.ts +26 -0
  261. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  262. package/src/modules/workspace/kb-repository.ts +844 -0
  263. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  264. package/src/modules/workspace/routine-write-policy.ts +0 -15
  265. package/src/modules/workspace/set-aside-clone.ts +140 -0
  266. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  267. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  268. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  269. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  270. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  271. package/src/modules/workspace/workspace.service.ts +247 -169
  272. package/src/modules/workspace/workspace.tools.ts +299 -385
  273. package/src/shared/workspace-id.ts +24 -0
  274. package/src/test-setup.ts +5 -0
@@ -1,16 +1,15 @@
1
- import { spawn } from 'node:child_process';
2
1
  import nodeFs from 'node:fs/promises';
3
2
  import { join } from 'node:path';
4
3
  import AdmZip from 'adm-zip';
5
4
  import { hasHttpStatus, ToolError } from '../tool-helpers/tool.contract.js';
6
5
  import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
7
6
  import { notifyAgentRead, assertAgentWriteAllowed, SESSION_ID_INPUT, } from './agent-access.gate.js';
7
+ import { CAN_WRITE_CLAUSE, defaultBranchWriteVerdicts } from '../tool-helpers/index.js';
8
8
  import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
9
9
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
10
10
  import { GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
11
11
  // Leaf-level shared primitive (same exception `workspace.service.ts` already
12
12
  // relies on) — not a workflow service, so this stays inside the module boundary.
13
- import { assertValidBranchName } from '../kb-fs/branch-name.js';
14
13
  import { assertInsideRepo, assertRepoRootNameFree, assertRepoRootNameFreeArgs, isInsideRepo, normalizePathArgs, normalizeWorkspacePath, } from '../kb-fs/repo-path.js';
15
14
  import { GitGuardedFilesystem } from '../kb-fs/git-guarded-filesystem.js';
16
15
  import { assertNoGitInternalsSegment, assertNotGitInternals, hasGitInternalsSegment } from '../../shared/git-internals.js';
@@ -28,6 +27,7 @@ import { folderPlaceholderPath, isFolderPlaceholder, isPlatformFile, isPlatformF
28
27
  import { AccessDeniedError } from '../access-model/access-errors.js';
29
28
  import { AGENT_GUIDE_FILE, isAgentGuidePath, isManagedGuide, withPlatformGuideAppended, } from '../agent-guide/agent-guide.js';
30
29
  import { removeEmptyDirs } from './empty-dirs.js';
30
+ import { endsTheSearch } from './pattern-matcher.contract.js';
31
31
  import { FIRST_RUN_SECTION_ID, firstRunNote, knowledgeFolderIsNew } from './first-run.js';
32
32
  import { planMoveLinks } from './move-links.js';
33
33
  import { MoveLockedError, MoveRacedError } from '../kb-fs/locking-filesystem.js';
@@ -487,7 +487,7 @@ function modeOf(a) {
487
487
  * Throws whatever reading the file throws — the caller decides whether that is
488
488
  * a file to skip (the walk) or an error to surface (a single-file grep).
489
489
  */
490
- async function grepOneFile(fs, path, re, out, max, docs) {
490
+ async function grepOneFile(fs, path, matcher, out, max, docs) {
491
491
  const reader = docs.readers.readerFor(path);
492
492
  const bytes = asBytes(await fs.readFile(path));
493
493
  let content = reader.greppableText ? await reader.greppableText(bytes, path) : null;
@@ -507,10 +507,10 @@ async function grepOneFile(fs, path, re, out, max, docs) {
507
507
  if (content === null)
508
508
  return 'no-text';
509
509
  const lines = content.split('\n');
510
- for (let i = 0; i < lines.length && out.length < max; i++) {
511
- re.lastIndex = 0;
512
- if (re.test(lines[i]))
513
- out.push({ path, line: i + 1, text: lines[i].slice(0, 300) });
510
+ // matchingLines posts these to the worker in bounded batches, so no one
511
+ // copy of a large file runs on the request thread.
512
+ for (const i of await matcher.matchingLines(lines, max - out.length)) {
513
+ out.push({ path, line: i + 1, text: lines[i].slice(0, 300) });
514
514
  }
515
515
  return 'searched';
516
516
  }
@@ -547,7 +547,7 @@ async function searchRootKind(fs, path) {
547
547
  }
548
548
  }
549
549
  /** JS grep over the workspace tree (read methods only) — bounded by match + depth caps. */
550
- async function grepWalk(fs, dir, re, out, max, depth, gate, notifyRead, docs,
550
+ async function grepWalk(fs, dir, matcher, out, max, depth, gate, notifyRead, docs,
551
551
  /** A file the walk leaves to its caller: never opened, never counted against `max`. */
552
552
  skip = () => false) {
553
553
  if (out.length >= max || depth > 12)
@@ -571,7 +571,7 @@ skip = () => false) {
571
571
  continue;
572
572
  const p = dir ? `${dir}/${e.name}` : e.name;
573
573
  if (e.type === 'directory') {
574
- await grepWalk(fs, p, re, out, max, depth + 1, gate, notifyRead, docs, skip);
574
+ await grepWalk(fs, p, matcher, out, max, depth + 1, gate, notifyRead, docs, skip);
575
575
  }
576
576
  else if (skip(p)) {
577
577
  continue;
@@ -583,10 +583,14 @@ skip = () => false) {
583
583
  await notifyRead(p);
584
584
  // A file the walk cannot read is silently skipped: one unreadable entry
585
585
  // must not fail a search over the whole tree.
586
+ // A pattern too expensive to finish, or no worker free to run it, is not
587
+ // an unreadable file: either ends the whole search.
586
588
  try {
587
- await grepOneFile(fs, p, re, out, max, docs);
589
+ await grepOneFile(fs, p, matcher, out, max, docs);
588
590
  }
589
- catch {
591
+ catch (err) {
592
+ if (endsTheSearch(err))
593
+ throw err;
590
594
  continue;
591
595
  }
592
596
  }
@@ -626,9 +630,10 @@ export function sessionIdInputOf(def) {
626
630
  * Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
627
631
  * methods Mastra's tools call (via `ctx.getFilesystem(a.branch as string)`), so behaviour is
628
632
  * identical and write-side ops still flow through the lock/commit pipeline.
629
- * `edit_file`, `grep`, and `execute_command` are tool-level (no filesystem
630
- * method), so they're implemented here. File ops are `both`; `execute_command`
631
- * is INTERNAL-only (arbitrary shell as the caller is too dangerous to expose).
633
+ * `edit_file` and `grep` are tool-level (no filesystem method), so they're
634
+ * implemented here. There is no shell tool: an agent never composes a command
635
+ * line, and every git operation it needs is a platform tool with fixed
636
+ * arguments.
632
637
  */
633
638
  export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kb, agentAccessGate, writePolicy, sessionSink,
634
639
  /**
@@ -680,7 +685,14 @@ starterPacks,
680
685
  * note's look at the knowledge folder. Optional for the harnesses about the
681
686
  * file primitives; without it `start_session` answers the id alone.
682
687
  */
683
- disk) {
688
+ disk,
689
+ /**
690
+ * What runs a caller's `grep` pattern off the request thread, under a
691
+ * budget (see `pattern-matcher.contract.ts`). Owned by the composition
692
+ * root, which closes it with the graph. Optional for the harnesses about the
693
+ * other file primitives; without it `grep` is not mounted.
694
+ */
695
+ patternMatcher) {
684
696
  const { kbDirName } = kb;
685
697
  /**
686
698
  * The one extension→reader registry every read-shaped decision routes
@@ -705,6 +717,23 @@ disk) {
705
717
  });
706
718
  /** A repo-relative path in the workspace-relative form every tool speaks. */
707
719
  const toWs = (rel) => (rel ? `${kbDirName}/${rel}` : kbDirName);
720
+ /**
721
+ * `canWrite` for workspace paths, as `list_files` and `read_file` report it:
722
+ * whether the caller may commit a change there directly on the DEFAULT
723
+ * branch, whichever branch the call read — one batch per call (see
724
+ * `defaultBranchWriteVerdicts`). The repository folder itself is judged as
725
+ * the repository root; a path outside the repository never lands on the
726
+ * default branch, so it is `false`.
727
+ */
728
+ const canWriteAt = async (ctx, wsPaths) => {
729
+ const relOf = (p) => {
730
+ const norm = p.replace(/^\/+/, '').replace(/\/+$/, '');
731
+ return norm === kbDirName ? '' : toKbRelative(norm, kbDirName);
732
+ };
733
+ const rels = wsPaths.map(relOf);
734
+ const verdicts = await defaultBranchWriteVerdicts({ accessControl, defaultWorkspaceId: () => kb.defaultWorkspaceId() }, ctx, rels.filter((r) => r !== null));
735
+ return new Map(wsPaths.map((p, i) => [p, rels[i] !== null && verdicts.get(rels[i]) === true]));
736
+ };
708
737
  const sourceToWs = (s) => (s ? { ...s, path: toWs(s.path) } : null);
709
738
  /**
710
739
  * What `file_stat` adds to its `access` verdicts when asked to explain them:
@@ -1296,9 +1325,9 @@ disk) {
1296
1325
  redescribe();
1297
1326
  kb.onLayoutApplied(redescribe);
1298
1327
  agentAccessGate.notes.onChange(redescribe);
1299
- // Internal-only tools (e.g. `execute_command`) keep their route mounted —
1300
- // our agent calls it over the same loopback — but gate it to internal-source
1301
- // callers so an external connection key can't invoke it by name.
1328
+ // Internal-only tools keep their route mounted — our agent calls them over
1329
+ // the same loopback — but gate them to internal-source callers so an
1330
+ // external connection key can't invoke one by name.
1302
1331
  router.post(path.slice('/api'.length), toolAuth, ...(spec.internalOnly ? [requireInternalSource] : []),
1303
1332
  // EVERY path input becomes a repository path here, once, before any
1304
1333
  // handler runs: the root-anchored `/<kbDirName>/…` form Copy path gives
@@ -1339,9 +1368,7 @@ disk) {
1339
1368
  catch (err) {
1340
1369
  return rethrowAsWriteDenial(err, { tool: spec.name, branch: args.branch, userEmail: ctx.user.email, userId: ctx.user.id }, accessControl, kb, changeGate);
1341
1370
  }
1342
- },
1343
- // `execute_command` resolves its own branch (see `resolvesBranchItself`).
1344
- { write: spec.write, writeScope: spec.writeScope, ...(spec.resolvesBranchItself ? { branch: 'own' } : {}) }));
1371
+ }, { write: spec.write, writeScope: spec.writeScope }));
1345
1372
  };
1346
1373
  // ── session bootstrap (external agents) ─────────────────────────────────
1347
1374
  // Every read/write tool below takes a `sessionId`: the conversation the
@@ -1467,7 +1494,14 @@ disk) {
1467
1494
  * hook, the extraction and the not-found message all have to match, and a
1468
1495
  * copy drifts on the first change to any of them.
1469
1496
  */
1470
- const readForTool = async (branch, p, ctx) => {
1497
+ const readForTool = async (branch, p, ctx) => (await readServed(branch, p, ctx)).result;
1498
+ /**
1499
+ * {@link readForTool}, also saying, at the guide's name, whether a file of
1500
+ * the knowledge base's own answered first on THIS branch (`guide`: `own`)
1501
+ * or the platform's guide answered alone (`alone`); `null` for any other
1502
+ * path. `read_file` needs it to judge `canWrite` there (see its handler).
1503
+ */
1504
+ const readServed = async (branch, p, ctx) => {
1471
1505
  // The guide's name at the repository root answers with the platform's
1472
1506
  // guide, which is text the code owns and every agent may read: no gate
1473
1507
  // and no read hook for it. A file the knowledge base keeps under that
@@ -1476,7 +1510,8 @@ disk) {
1476
1510
  // guide an earlier release wrote to disk (still on a draft, say) is
1477
1511
  // recognised by its header and not served a second time.
1478
1512
  if (agentGuide && isAgentGuidePath(toKbRelative(p, kbDirName) ?? '')) {
1479
- return { kind: 'text', text: await guideAt(branch, ctx, p) };
1513
+ const { text, own } = await guideServed(branch, ctx, p);
1514
+ return { result: { kind: 'text', text }, guide: own ? 'own' : 'alone' };
1480
1515
  }
1481
1516
  await notifyAgentRead(agentAccessGate, ctx, branch, p);
1482
1517
  await assertCanRead(readGateFor(branch, ctx), p);
@@ -1485,18 +1520,38 @@ disk) {
1485
1520
  // the access gate and the read hook above — a document read is still a
1486
1521
  // KB read. ONE registry dispatch picks the reader by extension.
1487
1522
  const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
1488
- return readers.readerFor(p).read(bytes, p);
1523
+ return { result: await readers.readerFor(p).read(bytes, p), guide: null };
1524
+ };
1525
+ /**
1526
+ * Whether the DEFAULT branch has a file of the knowledge base's own at the
1527
+ * guide's name that this caller may read — not a copy of the platform's
1528
+ * guide an earlier release wrote. Telling the two apart reads the file, so
1529
+ * that branch's read hook is asked first, as {@link ownGuideFile} does —
1530
+ * after the gate, before the bytes. A refusal, like any failure, is
1531
+ * `false`, so `canWrite` fails closed.
1532
+ */
1533
+ const ownGuideOnDefault = async (ctx, p) => {
1534
+ try {
1535
+ const fs = await ctx.getFilesystem(kb.defaultBranch);
1536
+ if (!(await ownGuideReadable(fs, kb.defaultBranch, ctx, p)))
1537
+ return false;
1538
+ await notifyAgentRead(agentAccessGate, ctx, kb.defaultBranch, p);
1539
+ return await isOwnEntryStill(fs, p);
1540
+ }
1541
+ catch {
1542
+ return false;
1543
+ }
1489
1544
  };
1490
1545
  mount({
1491
1546
  name: 'read_file',
1492
1547
  gated: true,
1493
- description: 'Read a workspace file as text. Returns `{ path, content }`. What comes back for a document, an email file, an image ' +
1494
- 'or any other binary file is the content rule\'s business (see the shared rules): text files as text, documents and ' +
1495
- 'email files as extracted text, an image as the picture itself, anything else as a one-line description. ' +
1496
- 'Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored ' +
1497
- 'for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. ' +
1498
- 'It also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: such a ref belongs to no ' +
1499
- 'workspace, so `branch` is ignored for it.',
1548
+ description: 'Read a workspace file as text. Returns `{ path, content, canWrite }`. Per the shared content rule: text as ' +
1549
+ 'text, documents and email as extracted text, an image as the picture, anything else as a one-line ' +
1550
+ 'description. Also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: it ' +
1551
+ 'belongs to no workspace, so `branch` is ignored and no `canWrite` comes back. `offset`/`limit` slice the ' +
1552
+ 'content (characters; bytes for a spill ref; ignored for an image): page through large files and ' +
1553
+ 'spills, never read multi-MB whole. `canWrite`: may you change this file directly on the default branch. ' +
1554
+ CAN_WRITE_CLAUSE,
1500
1555
  inputs: {
1501
1556
  type: 'object',
1502
1557
  properties: {
@@ -1511,7 +1566,15 @@ disk) {
1511
1566
  },
1512
1567
  outputs: {
1513
1568
  type: 'object',
1514
- properties: { path: str('The path that was read (echoes the input).'), content: str('File (or spill) content, sliced if offset/limit were given.') },
1569
+ properties: {
1570
+ path: str('The path that was read (echoes the input).'),
1571
+ content: str('File (or spill) content, sliced if offset/limit were given.'),
1572
+ canWrite: {
1573
+ type: 'boolean',
1574
+ description: 'Whether you may commit a change to this file directly on the default branch, under that branch\'s access ' +
1575
+ 'rules — the same answer whichever branch you read. Absent for a spill ref.',
1576
+ },
1577
+ },
1515
1578
  required: ['path', 'content'],
1516
1579
  },
1517
1580
  write: false,
@@ -1528,7 +1591,18 @@ disk) {
1528
1591
  ? content.slice(start, limit !== undefined ? start + limit : undefined)
1529
1592
  : content;
1530
1593
  };
1531
- const result = await readForTool(a.branch, p, ctx);
1594
+ const branch = a.branch;
1595
+ const { result, guide } = await readServed(branch, p, ctx);
1596
+ // Judged after the read, so a path the caller may not read is refused
1597
+ // before anything is said about writing it. At the guide's name this
1598
+ // answers for the knowledge base's own file there, as `file_stat` does:
1599
+ // where the platform's guide stands alone there is nothing to change —
1600
+ // `false`, whether nothing is there or a file the caller may not read.
1601
+ // Like every verdict here, that is decided on the DEFAULT branch, not
1602
+ // the one read: a draft that deleted or added the file must not move
1603
+ // the answer about what can land directly.
1604
+ const ownOnDefault = guide === null || (branch === kb.defaultBranch ? guide === 'own' : await ownGuideOnDefault(ctx, p));
1605
+ const canWrite = ownOnDefault && (await canWriteAt(ctx, [p])).get(p) === true;
1532
1606
  // Images return the picture itself as an MCP image content block, so a
1533
1607
  // multimodal model SEES it. The handler returns the `McpImageResult`
1534
1608
  // sentinel; the MCP result shaping (`toCallToolResult` in
@@ -1540,14 +1614,17 @@ disk) {
1540
1614
  // content blocks before any client could try to validate it, so the
1541
1615
  // schema keeps describing the text path it has always described.
1542
1616
  // `offset`/`limit` are meaningless on a picture and are ignored.
1617
+ // The picture's answer has no fields beside it, so its `canWrite` rides
1618
+ // in the text note that accompanies it.
1543
1619
  if (result.kind === 'image') {
1544
- return mcpImageResult(result.data, result.mimeType, result.note);
1620
+ const verdict = `canWrite: ${canWrite}`;
1621
+ return mcpImageResult(result.data, result.mimeType, result.note ? `${result.note} (${verdict})` : verdict);
1545
1622
  }
1546
1623
  // Text and refusals alike land in `content` — a refusal (corrupt
1547
1624
  // document, unreadable binary, oversized image) IS the file's honest
1548
1625
  // textual answer, sliced like any other content.
1549
1626
  const content = result.kind === 'text' ? result.text : result.message;
1550
- return { path: p, content: slice(content) };
1627
+ return { path: p, content: slice(content), canWrite };
1551
1628
  },
1552
1629
  });
1553
1630
  /**
@@ -1566,10 +1643,12 @@ disk) {
1566
1643
  * node is indistinguishable from an absent one on every other read.
1567
1644
  */
1568
1645
  /** What a read of the guide's path answers: the guide, after the knowledge base's own readable file when it has one. */
1569
- const guideAt = async (branch, ctx, p) => {
1646
+ const guideAt = async (branch, ctx, p) => (await guideServed(branch, ctx, p)).text;
1647
+ /** {@link guideAt}, also saying whether a file of the knowledge base's own came first. */
1648
+ const guideServed = async (branch, ctx, p) => {
1570
1649
  const guide = await agentGuide();
1571
1650
  const own = await ownGuideFile(branch, ctx, p);
1572
- return own === null ? guide : withPlatformGuideAppended(own, guide);
1651
+ return own === null ? { text: guide, own: false } : { text: withPlatformGuideAppended(own, guide), own: true };
1573
1652
  };
1574
1653
  const ownGuideFile = async (branch, ctx, p) => {
1575
1654
  const fs = await ctx.getFilesystem(branch);
@@ -1646,7 +1725,8 @@ disk) {
1646
1725
  mount({
1647
1726
  name: 'list_files',
1648
1727
  gated: true,
1649
- 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.`,
1728
+ description: `List a directory. Returns \`{ path, entries: [{ name, type, size?, canWrite }] }\`. 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. ` +
1729
+ `\`canWrite\`: may you change that file, or add a file directly inside that folder, directly on the default branch. ${CAN_WRITE_CLAUSE}`,
1650
1730
  inputs: {
1651
1731
  type: 'object',
1652
1732
  properties: {
@@ -1666,8 +1746,18 @@ disk) {
1666
1746
  description: 'Directory entries.',
1667
1747
  items: {
1668
1748
  type: 'object',
1669
- properties: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes (files only).') },
1670
- required: ['name', 'type'],
1749
+ properties: {
1750
+ name: str('Entry name.'),
1751
+ type: str('`file` or `directory`.'),
1752
+ size: int('Size in bytes (files only).'),
1753
+ canWrite: {
1754
+ type: 'boolean',
1755
+ description: 'Whether you may commit directly on the default branch, under that branch\'s access rules: for a ' +
1756
+ 'file, a change to it; for a folder, a new file directly inside it, by that folder\'s own rules. ' +
1757
+ 'The same answer whichever branch you listed.',
1758
+ },
1759
+ },
1760
+ required: ['name', 'type', 'canWrite'],
1671
1761
  },
1672
1762
  },
1673
1763
  },
@@ -1680,7 +1770,12 @@ disk) {
1680
1770
  const fs = await ctx.getFilesystem(a.branch);
1681
1771
  const entries = withoutPlaceholder((await fs.readdir(dir || '.')));
1682
1772
  const filtered = await filterReadableEntries(readGateFor(a.branch, ctx), dir, entries);
1683
- return { path: a.path ?? '', entries: filtered };
1773
+ // One batch for the listing. A folder is judged at its own path, whose
1774
+ // rules (its `access.md` and those above it) are what a file added
1775
+ // directly inside it would get.
1776
+ const wsPathOf = (e) => (dir ? `${dir}/${e.name}` : e.name);
1777
+ const verdicts = await canWriteAt(ctx, filtered.map(wsPathOf));
1778
+ return { path: a.path ?? '', entries: filtered.map((e) => ({ ...e, canWrite: verdicts.get(wsPathOf(e)) === true })) };
1684
1779
  },
1685
1780
  });
1686
1781
  mount({
@@ -1919,147 +2014,146 @@ disk) {
1919
2014
  return { ...out, ...fileTypeOf(reader, p, bytes) };
1920
2015
  },
1921
2016
  });
1922
- mount({
1923
- name: 'grep',
1924
- gated: true,
1925
- description: 'Regex 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).',
1926
- inputs: {
1927
- type: 'object',
1928
- properties: {
1929
- branch: BRANCH_INPUT,
1930
- pattern: str('JavaScript regular expression.'),
1931
- path: str(`Subtree to search, or a single file to search on its own, with or without a leading slash — a path without the \`${kbDirName}/\` prefix is placed under \`${kbDirName}/\` (default: the whole repository, \`${kbDirName}/\`).`),
1932
- ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
1933
- max_results: { type: 'integer', minimum: 1, maximum: 1000, description: 'Cap on matches (default 200).' },
1934
- sessionId: SESSION_ID_INPUT,
2017
+ // Mounted only when the composition supplied a matcher — see the
2018
+ // `patternMatcher` parameter. Core always does.
2019
+ if (patternMatcher) {
2020
+ const patterns = patternMatcher;
2021
+ mount({
2022
+ name: 'grep',
2023
+ gated: true,
2024
+ description: 'Regex 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).',
2025
+ inputs: {
2026
+ type: 'object',
2027
+ properties: {
2028
+ branch: BRANCH_INPUT,
2029
+ pattern: str('JavaScript regular expression.'),
2030
+ path: str(`Subtree to search, or a single file to search on its own, with or without a leading slash — a path without the \`${kbDirName}/\` prefix is placed under \`${kbDirName}/\` (default: the whole repository, \`${kbDirName}/\`).`),
2031
+ ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
2032
+ max_results: { type: 'integer', minimum: 1, maximum: 1000, description: 'Cap on matches (default 200).' },
2033
+ sessionId: SESSION_ID_INPUT,
2034
+ },
2035
+ required: ['branch', 'pattern'],
2036
+ additionalProperties: false,
1935
2037
  },
1936
- required: ['branch', 'pattern'],
1937
- additionalProperties: false,
1938
- },
1939
- outputs: {
1940
- type: 'object',
1941
- properties: {
1942
- matches: {
1943
- type: 'array',
1944
- description: 'Matching lines (capped by `max_results`).',
1945
- items: {
1946
- type: 'object',
1947
- properties: { path: str('Workspace-relative file path.'), line: int('1-based line number.'), text: str('The matching line (truncated to 300 chars).') },
1948
- required: ['path', 'line', 'text'],
2038
+ outputs: {
2039
+ type: 'object',
2040
+ properties: {
2041
+ matches: {
2042
+ type: 'array',
2043
+ description: 'Matching lines (capped by `max_results`).',
2044
+ items: {
2045
+ type: 'object',
2046
+ properties: { path: str('Workspace-relative file path.'), line: int('1-based line number.'), text: str('The matching line (truncated to 300 chars).') },
2047
+ required: ['path', 'line', 'text'],
2048
+ },
1949
2049
  },
2050
+ truncated: { type: 'boolean', description: 'True if the match cap was hit and results may be incomplete.' },
2051
+ note: str('Present when the empty/partial result needs explaining: a `path` naming a file with no searchable text (image, binary, corrupt document), or documents (office/PDF/email files) left unsearched because their text was not yet extracted and the per-call extraction budget ran out — re-run grep to cover those.'),
1950
2052
  },
1951
- truncated: { type: 'boolean', description: 'True if the match cap was hit and results may be incomplete.' },
1952
- note: str('Present when the empty/partial result needs explaining: a `path` naming a file with no searchable text (image, binary, corrupt document), or documents (office/PDF/email files) left unsearched because their text was not yet extracted and the per-call extraction budget ran out — re-run grep to cover those.'),
2053
+ required: ['matches', 'truncated'],
1953
2054
  },
1954
- required: ['matches', 'truncated'],
1955
- },
1956
- write: false,
1957
- handler: async (a, ctx) => {
1958
- let re;
1959
- try {
1960
- re = new RegExp(a.pattern, a.ignore_case ? 'i' : '');
1961
- }
1962
- catch (err) {
1963
- throw new ToolError(`Invalid regex: ${err.message}`, 400);
1964
- }
1965
- // No path means the whole REPOSITORY, not the workspace directory above
1966
- // it. The workspace root also holds whatever an older build left beside
1967
- // the checkout, and the read gate has no rules for a path outside the
1968
- // repository — it answers "readable" — so a walk from there would hand
1969
- // any caller the contents of every stray, including documents that were
1970
- // uploaded to a restricted folder and landed beside it instead. The
1971
- // boot note names those for an operator; no tool reads them.
1972
- // An EMPTY string is the same absence: the normaliser leaves it alone
1973
- // (an empty path is the handler's to explain), and here it would
1974
- // otherwise name the workspace directory by another spelling.
1975
- const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
1976
- const fs = await ctx.getFilesystem(a.branch);
1977
- const gate = readGateFor(a.branch, ctx);
1978
- const out = [];
1979
- const max = typeof a.max_results === 'number' ? Math.min(a.max_results, 1000) : 200;
1980
- const docs = { readers, uncachedBudget: UNCACHED_DOCS_PER_GREP, skippedUncached: 0 };
1981
- // The empty root is the workspace itself — always a directory, and never
1982
- // worth a stat.
1983
- // A placeholder named on its own is searched as what it is to every
1984
- // other tool: nothing.
1985
- const kind = searchRoot === ''
1986
- ? 'directory'
1987
- : isFolderPlaceholder(searchRoot)
1988
- ? 'missing'
1989
- : await searchRootKind(fs, searchRoot);
1990
- /** Why a single-file search found nothing, when "no matches" would be a lie. */
1991
- let fileNote;
1992
- // The guide is searched where it is read: a search of the repository
1993
- // root covers it, and a search of its own path is a search of what
1994
- // `read_file` answers there. The composed text is what is searched —
1995
- // the knowledge base's own readable file first, then the platform's
1996
- // guide — under the guide's path and with the line numbers a read of
1997
- // it gives. The walk leaves that one file to this: a match the walk
1998
- // made there would be the same line again, and one it COUNTED against
1999
- // `max_results` would be a file later in the tree never searched while
2000
- // the answer says nothing was cut. A file the caller may not read is
2001
- // absent from it, as it is from the read.
2002
- const guidePath = `${kbDirName}/${AGENT_GUIDE_FILE}`;
2003
- const rel = toKbRelative(searchRoot, kbDirName);
2004
- const coversGuide = agentGuide !== undefined && (kind === 'directory' ? rel === null : isAgentGuidePath(rel ?? ''));
2005
- // The search root goes to the read hook — once, and only when it is
2006
- // repository content: a folder, or a file of the knowledge base's own.
2007
- // The guide's own path is not told here, because `guideAt` tells the
2008
- // hook of the organisation's file there exactly as `read_file` does
2009
- // (after the gate), and the platform's guide alone is nobody's file to
2010
- // note. Each file the walk opens goes to the hook per file below, so a
2011
- // hook sees every path a grep reached rather than only its root.
2012
- if (!(coversGuide && kind !== 'directory')) {
2013
- await notifyAgentRead(agentAccessGate, ctx, a.branch, searchRoot);
2014
- }
2015
- if (coversGuide) {
2016
- if (kind === 'directory') {
2017
- await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) => notifyAgentRead(agentAccessGate, ctx, a.branch, p), docs, (p) => p === guidePath);
2055
+ write: false,
2056
+ handler: async (a, ctx) => {
2057
+ // The caller's pattern never runs on the request thread: a backtracking
2058
+ // one would hold the event loop for everyone (see IPatternMatcher).
2059
+ const matcher = patterns.search(a.pattern, a.ignore_case ? 'i' : '');
2060
+ // No path means the whole REPOSITORY, not the workspace directory above
2061
+ // it. The workspace root also holds whatever an older build left beside
2062
+ // the checkout, and the read gate has no rules for a path outside the
2063
+ // repository — it answers "readable" — so a walk from there would hand
2064
+ // any caller the contents of every stray, including documents that were
2065
+ // uploaded to a restricted folder and landed beside it instead. The
2066
+ // boot note names those for an operator; no tool reads them.
2067
+ // An EMPTY string is the same absence: the normaliser leaves it alone
2068
+ // (an empty path is the handler's to explain), and here it would
2069
+ // otherwise name the workspace directory by another spelling.
2070
+ const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
2071
+ const fs = await ctx.getFilesystem(a.branch);
2072
+ const gate = readGateFor(a.branch, ctx);
2073
+ const out = [];
2074
+ const max = typeof a.max_results === 'number' ? Math.min(a.max_results, 1000) : 200;
2075
+ const docs = { readers, uncachedBudget: UNCACHED_DOCS_PER_GREP, skippedUncached: 0 };
2076
+ // The empty root is the workspace itself — always a directory, and never
2077
+ // worth a stat.
2078
+ // A placeholder named on its own is searched as what it is to every
2079
+ // other tool: nothing.
2080
+ const kind = searchRoot === ''
2081
+ ? 'directory'
2082
+ : isFolderPlaceholder(searchRoot)
2083
+ ? 'missing'
2084
+ : await searchRootKind(fs, searchRoot);
2085
+ /** Why a single-file search found nothing, when "no matches" would be a lie. */
2086
+ let fileNote;
2087
+ // The guide is searched where it is read: a search of the repository
2088
+ // root covers it, and a search of its own path is a search of what
2089
+ // `read_file` answers there. The composed text is what is searched —
2090
+ // the knowledge base's own readable file first, then the platform's
2091
+ // guide — under the guide's path and with the line numbers a read of
2092
+ // it gives. The walk leaves that one file to this: a match the walk
2093
+ // made there would be the same line again, and one it COUNTED against
2094
+ // `max_results` would be a file later in the tree never searched while
2095
+ // the answer says nothing was cut. A file the caller may not read is
2096
+ // absent from it, as it is from the read.
2097
+ const guidePath = `${kbDirName}/${AGENT_GUIDE_FILE}`;
2098
+ const rel = toKbRelative(searchRoot, kbDirName);
2099
+ const coversGuide = agentGuide !== undefined && (kind === 'directory' ? rel === null : isAgentGuidePath(rel ?? ''));
2100
+ // The search root goes to the read hook — once, and only when it is
2101
+ // repository content: a folder, or a file of the knowledge base's own.
2102
+ // The guide's own path is not told here, because `guideAt` tells the
2103
+ // hook of the organisation's file there exactly as `read_file` does
2104
+ // (after the gate), and the platform's guide alone is nobody's file to
2105
+ // note. Each file the walk opens goes to the hook per file below, so a
2106
+ // hook sees every path a grep reached rather than only its root.
2107
+ if (!(coversGuide && kind !== 'directory')) {
2108
+ await notifyAgentRead(agentAccessGate, ctx, a.branch, searchRoot);
2018
2109
  }
2019
- const lines = (await guideAt(a.branch, ctx, guidePath)).split('\n');
2020
- for (let i = 0; i < lines.length && out.length < max; i++) {
2021
- re.lastIndex = 0;
2022
- if (re.test(lines[i]))
2110
+ if (coversGuide) {
2111
+ if (kind === 'directory') {
2112
+ await grepWalk(fs, searchRoot, matcher, out, max, 0, gate, (p) => notifyAgentRead(agentAccessGate, ctx, a.branch, p), docs, (p) => p === guidePath);
2113
+ }
2114
+ const lines = (await guideAt(a.branch, ctx, guidePath)).split('\n');
2115
+ for (const i of await matcher.matchingLines(lines, max - out.length)) {
2023
2116
  out.push({ path: guidePath, line: i + 1, text: lines[i].slice(0, 300) });
2117
+ }
2024
2118
  }
2025
- }
2026
- else if (kind === 'directory') {
2027
- await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) => notifyAgentRead(agentAccessGate, ctx, a.branch, p), docs);
2028
- }
2029
- else {
2030
- // Not a directory: the permission verdict comes BEFORE every other
2031
- // one, and it is `read_file`'s own gate on the same path — so grep
2032
- // answers a path the caller may not read exactly as read_file does,
2033
- // and can never confirm the existence of one read_file would hide.
2034
- // That ordering holds even when the stat itself failed: a denied path
2035
- // gets the 403, never the filesystem's complaint about it.
2036
- await assertCanRead(gate, searchRoot);
2037
- if (kind === 'missing')
2038
- throw notFound(searchRoot, 'Nothing to search');
2039
- // A named FILE is searched directly: routing it through the walk would
2040
- // fail its readdir and answer an empty match list, which the caller
2041
- // cannot tell from "the pattern is not in this file".
2042
- const outcome = await orNotFound(searchRoot, () => grepOneFile(fs, searchRoot, re, out, max, docs), 'Nothing to search');
2043
- if (outcome === 'no-text') {
2044
- fileNote =
2045
- `"${displayPath(searchRoot)}" has no searchable text — it is an image, binary content, or a document ` +
2046
- 'whose text could not be extracted. There were no matches because there was nothing to search, not ' +
2047
- 'because the pattern is absent.';
2119
+ else if (kind === 'directory') {
2120
+ await grepWalk(fs, searchRoot, matcher, out, max, 0, gate, (p) => notifyAgentRead(agentAccessGate, ctx, a.branch, p), docs);
2048
2121
  }
2049
- }
2050
- const note = fileNote ??
2051
- (docs.skippedUncached > 0
2052
- ? `${docs.skippedUncached} document(s) (office/PDF/email files) were not searched: their text was not yet ` +
2053
- `extracted and this call's extraction budget (${UNCACHED_DOCS_PER_GREP}) ran out. Re-run the ` +
2054
- 'same grep to extract and search the next batch.'
2055
- : undefined);
2056
- return {
2057
- matches: out,
2058
- truncated: out.length >= max,
2059
- ...(note !== undefined ? { note } : {}),
2060
- };
2061
- },
2062
- });
2122
+ else {
2123
+ // Not a directory: the permission verdict comes BEFORE every other
2124
+ // one, and it is `read_file`'s own gate on the same path — so grep
2125
+ // answers a path the caller may not read exactly as read_file does,
2126
+ // and can never confirm the existence of one read_file would hide.
2127
+ // That ordering holds even when the stat itself failed: a denied path
2128
+ // gets the 403, never the filesystem's complaint about it.
2129
+ await assertCanRead(gate, searchRoot);
2130
+ if (kind === 'missing')
2131
+ throw notFound(searchRoot, 'Nothing to search');
2132
+ // A named FILE is searched directly: routing it through the walk would
2133
+ // fail its readdir and answer an empty match list, which the caller
2134
+ // cannot tell from "the pattern is not in this file".
2135
+ const outcome = await orNotFound(searchRoot, () => grepOneFile(fs, searchRoot, matcher, out, max, docs), 'Nothing to search');
2136
+ if (outcome === 'no-text') {
2137
+ fileNote =
2138
+ `"${displayPath(searchRoot)}" has no searchable text — it is an image, binary content, or a document ` +
2139
+ 'whose text could not be extracted. There were no matches because there was nothing to search, not ' +
2140
+ 'because the pattern is absent.';
2141
+ }
2142
+ }
2143
+ const note = fileNote ??
2144
+ (docs.skippedUncached > 0
2145
+ ? `${docs.skippedUncached} document(s) (office/PDF/email files) were not searched: their text was not yet ` +
2146
+ `extracted and this call's extraction budget (${UNCACHED_DOCS_PER_GREP}) ran out. Re-run the ` +
2147
+ 'same grep to extract and search the next batch.'
2148
+ : undefined);
2149
+ return {
2150
+ matches: out,
2151
+ truncated: out.length >= max,
2152
+ ...(note !== undefined ? { note } : {}),
2153
+ };
2154
+ },
2155
+ });
2156
+ }
2063
2157
  // ── writes (through the lock/commit pipeline) ───────────────────────────
2064
2158
  mount({
2065
2159
  name: 'write_file',
@@ -3503,188 +3597,6 @@ disk) {
3503
3597
  };
3504
3598
  });
3505
3599
  }
3506
- // ── shell (internal-only) ───────────────────────────────────────────────
3507
- mount({
3508
- name: 'execute_command',
3509
- gated: true,
3510
- 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.',
3511
- internalOnly: true,
3512
- fileTool: false,
3513
- // The one tool the mount's branch check skips: the handler below resolves an
3514
- // omitted `branch` to the internal caller's focused branch before refusing.
3515
- resolvesBranchItself: true,
3516
- inputs: {
3517
- type: 'object',
3518
- properties: {
3519
- branch: BRANCH_INPUT,
3520
- command: str('The shell command line to run.'),
3521
- timeout_ms: { type: 'integer', minimum: 1000, maximum: 120000, description: 'Timeout in ms (default 30000).' },
3522
- sessionId: SESSION_ID_INPUT,
3523
- },
3524
- // `branch` stays REQUIRED here on purpose, and must not be relaxed to make
3525
- // the handler's focused-branch fallback "reachable". This list is the
3526
- // contract the model is TAUGHT — declaring it required is what makes the
3527
- // caller always name its branch, which is the fix for the workspace this
3528
- // tool used to resolve as "undefined". It is not a runtime gate: nothing in
3529
- // the tool route validates inputs against this schema, so a branch-less call
3530
- // still reaches the handler and still hits the `ctx.focusedBranch` fallback
3531
- // (covered by "falls back to the session focused branch when branch is
3532
- // omitted"). The fallback is a safety net for a model that disobeys the
3533
- // contract — not a sanctioned calling convention to advertise.
3534
- required: ['branch', 'command'],
3535
- additionalProperties: false,
3536
- },
3537
- outputs: {
3538
- type: 'object',
3539
- properties: {
3540
- stdout: str('Captured standard output (truncated to 50,000 chars).'),
3541
- stderr: str('Captured standard error (truncated to 50,000 chars).'),
3542
- exitCode: int('Process exit code; -1 if it was killed (timeout/abort) or failed to spawn.'),
3543
- },
3544
- required: ['stdout', 'stderr', 'exitCode'],
3545
- },
3546
- write: true,
3547
- handler: async (a, ctx) => {
3548
- // Resolve the branch FIRST — before the policy gates below — so a malformed
3549
- // call always gets the 400 that names what is missing, rather than a 403
3550
- // from a restricted session masking it. Unlike the file tools (which route
3551
- // through `getOrCreateForUser` and fall back to the default branch),
3552
- // execute_command turns `branch` straight into a workspace — so a bad value
3553
- // would `encodeURIComponent(undefined)` to the string "undefined", then try
3554
- // to CLONE a branch literally named "undefined", 500ing and leaving an
3555
- // `<workspaces>/undefined/` shell behind.
3556
- //
3557
- // Two distinct cases:
3558
- // - branch OMITTED (`undefined`): the in-app chat agent may leave it off a
3559
- // call. For an internal session we fall back to the caller's own focused
3560
- // branch (`ctx.focusedBranch`, from its signed token), so the command runs
3561
- // against the workspace the session is on (e.g. `main`) end to end. An
3562
- // external caller carries no focused branch, so it stays absent → 400.
3563
- // - branch PRESENT but invalid (the literal "undefined"/"null", empty, or
3564
- // malformed): a broken value, never a real branch — fail closed, never
3565
- // silently reinterpret it as the focused branch.
3566
- const raw = a.branch;
3567
- const branch = raw === undefined ? ctx.focusedBranch : raw;
3568
- if (typeof branch !== 'string' || branch.length === 0) {
3569
- throw new ToolError('execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.', 400,
3570
- // The same discriminator every other KB tool answers a branch-less
3571
- // call with, so a client switches on one kind across the surface.
3572
- // The MESSAGE stays this tool's own: it can name the focused-branch
3573
- // fallback that only applies here.
3574
- { kind: 'branch-required' });
3575
- }
3576
- // A stringified absent value. Both are syntactically valid git branch names,
3577
- // so `assertValidBranchName` below happily accepts them — and accepting one
3578
- // is the whole bug this tool is guarded for: `getOrCreateForBranch("undefined")`
3579
- // clones a branch literally named "undefined" into `<workspaces>/undefined/`
3580
- // and 500s. Reject them by name, ahead of the shape check.
3581
- if (branch === 'undefined' || branch === 'null') {
3582
- throw new ToolError(`execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
3583
- 'Pass the real branch (draft) whose workspace to run the command in.', 400, { kind: 'branch-required' });
3584
- }
3585
- // Then the SHAPE, via the one canonical validator every other branch path
3586
- // uses — no hand-maintained list of suspicious literals, which would both
3587
- // miss malformed refs (`..`, `-x`, `foo/.lock`) and reserve names git
3588
- // considers perfectly valid. Runs on the RAW string, so a whitespace-padded
3589
- // `" main "` is refused rather than silently trimmed into a different
3590
- // branch than the caller passed.
3591
- try {
3592
- assertValidBranchName(branch);
3593
- }
3594
- catch (err) {
3595
- throw new ToolError(`execute_command got an invalid \`branch\`: ${err.message} — ` +
3596
- 'pass the exact branch (draft) whose workspace to run the command in.', 400);
3597
- }
3598
- // Shell is a write path with no single target path to check, so the
3599
- // write hook is asked once for the call itself, with no path — and the
3600
- // run must not be restricted to a file type either, since shell could
3601
- // write anything.
3602
- writePolicy.assertUnrestricted(ctx.sessionId);
3603
- await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
3604
- // Canonical per-branch bootstrap entry point — it owns the workspace-id
3605
- // encoding and the single-flight clone, so the shell never derives a
3606
- // workspace path by hand.
3607
- const cwd = (await ctx.workspaceService.getOrCreateForBranch(branch)).absolutePath;
3608
- const timeoutMs = typeof a.timeout_ms === 'number' ? a.timeout_ms : 30_000;
3609
- // cwd is the workspace ROOT so shell paths match the workspace-relative
3610
- // paths every other file tool uses — but the git clone lives one level
3611
- // deeper, at <workspace>/<kbDirName>/.git. For a bare `git status` /
3612
- // `git diff` (what the agent prompt teaches) point git there explicitly so
3613
- // it resolves the KB clone instead of failing with "not a git repository".
3614
- //
3615
- // Because the command runs under `shell: true`, GIT_DIR/GIT_WORK_TREE
3616
- // apply to the WHOLE shell, so restrict the override to a *standalone* bare
3617
- // `git …` invocation. Otherwise the KB repo context would (a) leak into a
3618
- // chained step that spawns its own git — `git commit && npm ci` — and (b)
3619
- // override a caller's explicit `git -C …` / `--git-dir` / `--work-tree`.
3620
- const command = a.command.trim();
3621
- const isStandaloneGit = /^git(\s|$)/.test(command) &&
3622
- !/[;&|`\n]|\$\(/.test(command) &&
3623
- !/(?:^|\s)(?:-C|--git-dir|--work-tree)(?:[=\s]|$)/.test(command);
3624
- const env = isStandaloneGit
3625
- ? {
3626
- ...process.env,
3627
- GIT_DIR: join(cwd, kbDirName, '.git'),
3628
- GIT_WORK_TREE: join(cwd, kbDirName),
3629
- }
3630
- : process.env;
3631
- return new Promise((resolve) => {
3632
- // Own process group on POSIX, so a timeout or abort kills the WHOLE
3633
- // tree. `shell: true` puts an `sh` between us and the command; killing
3634
- // only that shell left whatever it had started running on — orphaned,
3635
- // unreaped, still holding the workspace and its memory. A self-hosted
3636
- // deployment leaked ~4,500 tasks that way before nothing could fork.
3637
- const detached = process.platform !== 'win32';
3638
- const child = spawn(a.command, { cwd, shell: true, env, detached });
3639
- let stdout = '';
3640
- let stderr = '';
3641
- const killTree = () => {
3642
- try {
3643
- if (detached && child.pid)
3644
- process.kill(-child.pid, 'SIGKILL');
3645
- else
3646
- child.kill('SIGKILL');
3647
- }
3648
- catch {
3649
- try {
3650
- child.kill('SIGKILL');
3651
- }
3652
- catch {
3653
- // Already gone.
3654
- }
3655
- }
3656
- };
3657
- const timer = setTimeout(killTree, timeoutMs);
3658
- // If the caller disconnects (request aborted), kill the tree instead of
3659
- // letting it run to the timeout; the resulting `close`/`error` settles
3660
- // the promise through `finish`.
3661
- const onAbort = killTree;
3662
- ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
3663
- const finish = (value) => {
3664
- clearTimeout(timer);
3665
- ctx.abortSignal.removeEventListener('abort', onAbort);
3666
- resolve(value);
3667
- };
3668
- // Cap accumulation while streaming so a verbose command can't exhaust
3669
- // memory before `close`; the final slice keeps the output bound.
3670
- const OUTPUT_CAP = 50_000;
3671
- child.stdout.on('data', (d) => {
3672
- if (stdout.length < OUTPUT_CAP)
3673
- stdout += d.toString();
3674
- });
3675
- child.stderr.on('data', (d) => {
3676
- if (stderr.length < OUTPUT_CAP)
3677
- stderr += d.toString();
3678
- });
3679
- child.on('close', (code) => {
3680
- finish({ stdout: stdout.slice(0, 50_000), stderr: stderr.slice(0, 50_000), exitCode: code ?? -1 });
3681
- });
3682
- child.on('error', (err) => {
3683
- finish({ stdout, stderr: `${stderr}\n${String(err)}`.slice(0, 50_000), exitCode: -1 });
3684
- });
3685
- });
3686
- },
3687
- });
3688
3600
  // The one read `open_page` borrows — see `readForTool` above.
3689
3601
  return { readForTool };
3690
3602
  }