@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,4 +1,3 @@
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';
@@ -15,12 +14,12 @@ import {
15
14
  } from './agent-access.gate.js';
16
15
  import type { IRoutineWritePolicy } from './routine-write-policy.js';
17
16
  import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
17
+ import { CAN_WRITE_CLAUSE, defaultBranchWriteVerdicts } from '../tool-helpers/index.js';
18
18
  import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
19
19
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
20
20
  import { GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
21
21
  // Leaf-level shared primitive (same exception `workspace.service.ts` already
22
22
  // relies on) — not a workflow service, so this stays inside the module boundary.
23
- import { assertValidBranchName } from '../kb-fs/branch-name.js';
24
23
  import {
25
24
  assertInsideRepo,
26
25
  assertRepoRootNameFree,
@@ -69,6 +68,7 @@ import {
69
68
  type AgentGuideReader,
70
69
  } from '../agent-guide/agent-guide.js';
71
70
  import { removeEmptyDirs } from './empty-dirs.js';
71
+ import { endsTheSearch, type IPatternMatcher, type IPatternSearch } from './pattern-matcher.contract.js';
72
72
  import { FIRST_RUN_SECTION_ID, firstRunNote, knowledgeFolderIsNew, type FirstRunStarterSource } from './first-run.js';
73
73
  import { planMoveLinks } from './move-links.js';
74
74
  import { MoveLockedError, MoveRacedError, type LockingFilesystem } from '../kb-fs/locking-filesystem.js';
@@ -673,7 +673,7 @@ type FileGrepOutcome =
673
673
  async function grepOneFile(
674
674
  fs: LocalFilesystem,
675
675
  path: string,
676
- re: RegExp,
676
+ matcher: IPatternSearch,
677
677
  out: { path: string; line: number; text: string }[],
678
678
  max: number,
679
679
  docs: DocGrepState,
@@ -696,9 +696,10 @@ async function grepOneFile(
696
696
  }
697
697
  if (content === null) return 'no-text';
698
698
  const lines = content.split('\n');
699
- for (let i = 0; i < lines.length && out.length < max; i++) {
700
- re.lastIndex = 0;
701
- if (re.test(lines[i])) out.push({ path, line: i + 1, text: lines[i].slice(0, 300) });
699
+ // matchingLines posts these to the worker in bounded batches, so no one
700
+ // copy of a large file runs on the request thread.
701
+ for (const i of await matcher.matchingLines(lines, max - out.length)) {
702
+ out.push({ path, line: i + 1, text: lines[i]!.slice(0, 300) });
702
703
  }
703
704
  return 'searched';
704
705
  }
@@ -741,7 +742,7 @@ async function searchRootKind(
741
742
  async function grepWalk(
742
743
  fs: LocalFilesystem,
743
744
  dir: string,
744
- re: RegExp,
745
+ matcher: IPatternSearch,
745
746
  out: { path: string; line: number; text: string }[],
746
747
  max: number,
747
748
  depth: number,
@@ -767,7 +768,7 @@ async function grepWalk(
767
768
  if (e.type !== 'directory' && isFolderPlaceholder(e.name)) continue;
768
769
  const p = dir ? `${dir}/${e.name}` : e.name;
769
770
  if (e.type === 'directory') {
770
- await grepWalk(fs, p, re, out, max, depth + 1, gate, notifyRead, docs, skip);
771
+ await grepWalk(fs, p, matcher, out, max, depth + 1, gate, notifyRead, docs, skip);
771
772
  } else if (skip(p)) {
772
773
  continue;
773
774
  } else {
@@ -777,9 +778,12 @@ async function grepWalk(
777
778
  await notifyRead(p);
778
779
  // A file the walk cannot read is silently skipped: one unreadable entry
779
780
  // must not fail a search over the whole tree.
781
+ // A pattern too expensive to finish, or no worker free to run it, is not
782
+ // an unreadable file: either ends the whole search.
780
783
  try {
781
- await grepOneFile(fs, p, re, out, max, docs);
782
- } catch {
784
+ await grepOneFile(fs, p, matcher, out, max, docs);
785
+ } catch (err) {
786
+ if (endsTheSearch(err)) throw err;
783
787
  continue;
784
788
  }
785
789
  }
@@ -868,9 +872,10 @@ export interface WorkspaceToolsPorts {
868
872
  * Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
869
873
  * methods Mastra's tools call (via `ctx.getFilesystem(a.branch as string)`), so behaviour is
870
874
  * identical and write-side ops still flow through the lock/commit pipeline.
871
- * `edit_file`, `grep`, and `execute_command` are tool-level (no filesystem
872
- * method), so they're implemented here. File ops are `both`; `execute_command`
873
- * is INTERNAL-only (arbitrary shell as the caller is too dangerous to expose).
875
+ * `edit_file` and `grep` are tool-level (no filesystem method), so they're
876
+ * implemented here. There is no shell tool: an agent never composes a command
877
+ * line, and every git operation it needs is a platform tool with fixed
878
+ * arguments.
874
879
  */
875
880
  export function registerWorkspaceTools(
876
881
  registry: IToolRegistry,
@@ -934,6 +939,13 @@ export function registerWorkspaceTools(
934
939
  * file primitives; without it `start_session` answers the id alone.
935
940
  */
936
941
  disk?: ITreeWalker,
942
+ /**
943
+ * What runs a caller's `grep` pattern off the request thread, under a
944
+ * budget (see `pattern-matcher.contract.ts`). Owned by the composition
945
+ * root, which closes it with the graph. Optional for the harnesses about the
946
+ * other file primitives; without it `grep` is not mounted.
947
+ */
948
+ patternMatcher?: IPatternMatcher,
937
949
  ): WorkspaceToolsPorts {
938
950
  const { kbDirName } = kb;
939
951
  /**
@@ -965,6 +977,28 @@ export function registerWorkspaceTools(
965
977
 
966
978
  /** A repo-relative path in the workspace-relative form every tool speaks. */
967
979
  const toWs = (rel: string): string => (rel ? `${kbDirName}/${rel}` : kbDirName);
980
+
981
+ /**
982
+ * `canWrite` for workspace paths, as `list_files` and `read_file` report it:
983
+ * whether the caller may commit a change there directly on the DEFAULT
984
+ * branch, whichever branch the call read — one batch per call (see
985
+ * `defaultBranchWriteVerdicts`). The repository folder itself is judged as
986
+ * the repository root; a path outside the repository never lands on the
987
+ * default branch, so it is `false`.
988
+ */
989
+ const canWriteAt = async (ctx: ToolContext, wsPaths: string[]): Promise<Map<string, boolean>> => {
990
+ const relOf = (p: string): string | null => {
991
+ const norm = p.replace(/^\/+/, '').replace(/\/+$/, '');
992
+ return norm === kbDirName ? '' : toKbRelative(norm, kbDirName);
993
+ };
994
+ const rels = wsPaths.map(relOf);
995
+ const verdicts = await defaultBranchWriteVerdicts(
996
+ { accessControl, defaultWorkspaceId: () => kb.defaultWorkspaceId() },
997
+ ctx,
998
+ rels.filter((r): r is string => r !== null),
999
+ );
1000
+ return new Map(wsPaths.map((p, i) => [p, rels[i] !== null && verdicts.get(rels[i]!) === true]));
1001
+ };
968
1002
  const sourceToWs = (s: AccessDecisionSource | null) => (s ? { ...s, path: toWs(s.path) } : null);
969
1003
 
970
1004
  /**
@@ -1563,15 +1597,6 @@ export function registerWorkspaceTools(
1563
1597
  * read-only key may still not make (`request_file_download`).
1564
1598
  */
1565
1599
  writeScope?: boolean;
1566
- /**
1567
- * This tool resolves `branch` ITSELF and must not be pre-checked here.
1568
- * Only `execute_command` sets it: for an internal session that leaves the
1569
- * argument off, it falls back to the caller's own focused branch (from its
1570
- * signed token) rather than refusing. It still answers the same
1571
- * `branch-required` kind when there is nothing to fall back on — see its
1572
- * handler. Every other tool takes the check below.
1573
- */
1574
- resolvesBranchItself?: boolean;
1575
1600
  /** Arguments this tool refuses by name itself, with its own wording (see `ToolDefSpec.refusesItself`). */
1576
1601
  refusesItself?: string[];
1577
1602
  handler: ToolHandler;
@@ -1630,9 +1655,9 @@ export function registerWorkspaceTools(
1630
1655
  redescribe();
1631
1656
  kb.onLayoutApplied(redescribe);
1632
1657
  agentAccessGate.notes.onChange(redescribe);
1633
- // Internal-only tools (e.g. `execute_command`) keep their route mounted —
1634
- // our agent calls it over the same loopback — but gate it to internal-source
1635
- // callers so an external connection key can't invoke it by name.
1658
+ // Internal-only tools keep their route mounted — our agent calls them over
1659
+ // the same loopback — but gate them to internal-source callers so an
1660
+ // external connection key can't invoke one by name.
1636
1661
  router.post(
1637
1662
  path.slice('/api'.length),
1638
1663
  toolAuth,
@@ -1683,8 +1708,7 @@ export function registerWorkspaceTools(
1683
1708
  );
1684
1709
  }
1685
1710
  },
1686
- // `execute_command` resolves its own branch (see `resolvesBranchItself`).
1687
- { write: spec.write, writeScope: spec.writeScope, ...(spec.resolvesBranchItself ? { branch: 'own' as const } : {}) },
1711
+ { write: spec.write, writeScope: spec.writeScope },
1688
1712
  ),
1689
1713
  );
1690
1714
  };
@@ -1823,7 +1847,19 @@ export function registerWorkspaceTools(
1823
1847
  * hook, the extraction and the not-found message all have to match, and a
1824
1848
  * copy drifts on the first change to any of them.
1825
1849
  */
1826
- const readForTool: ReadForTool = async (branch, p, ctx) => {
1850
+ const readForTool: ReadForTool = async (branch, p, ctx) => (await readServed(branch, p, ctx)).result;
1851
+
1852
+ /**
1853
+ * {@link readForTool}, also saying, at the guide's name, whether a file of
1854
+ * the knowledge base's own answered first on THIS branch (`guide`: `own`)
1855
+ * or the platform's guide answered alone (`alone`); `null` for any other
1856
+ * path. `read_file` needs it to judge `canWrite` there (see its handler).
1857
+ */
1858
+ const readServed = async (
1859
+ branch: string,
1860
+ p: string,
1861
+ ctx: ToolContext,
1862
+ ): Promise<{ result: ReadResult; guide: 'own' | 'alone' | null }> => {
1827
1863
  // The guide's name at the repository root answers with the platform's
1828
1864
  // guide, which is text the code owns and every agent may read: no gate
1829
1865
  // and no read hook for it. A file the knowledge base keeps under that
@@ -1832,7 +1868,8 @@ export function registerWorkspaceTools(
1832
1868
  // guide an earlier release wrote to disk (still on a draft, say) is
1833
1869
  // recognised by its header and not served a second time.
1834
1870
  if (agentGuide && isAgentGuidePath(toKbRelative(p, kbDirName) ?? '')) {
1835
- return { kind: 'text', text: await guideAt(branch, ctx, p) };
1871
+ const { text, own } = await guideServed(branch, ctx, p);
1872
+ return { result: { kind: 'text', text }, guide: own ? 'own' : 'alone' };
1836
1873
  }
1837
1874
  await notifyAgentRead(agentAccessGate, ctx, branch, p);
1838
1875
  await assertCanRead(readGateFor(branch, ctx), p);
@@ -1841,20 +1878,39 @@ export function registerWorkspaceTools(
1841
1878
  // the access gate and the read hook above — a document read is still a
1842
1879
  // KB read. ONE registry dispatch picks the reader by extension.
1843
1880
  const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
1844
- return readers.readerFor(p).read(bytes, p);
1881
+ return { result: await readers.readerFor(p).read(bytes, p), guide: null };
1882
+ };
1883
+
1884
+ /**
1885
+ * Whether the DEFAULT branch has a file of the knowledge base's own at the
1886
+ * guide's name that this caller may read — not a copy of the platform's
1887
+ * guide an earlier release wrote. Telling the two apart reads the file, so
1888
+ * that branch's read hook is asked first, as {@link ownGuideFile} does —
1889
+ * after the gate, before the bytes. A refusal, like any failure, is
1890
+ * `false`, so `canWrite` fails closed.
1891
+ */
1892
+ const ownGuideOnDefault = async (ctx: ToolContext, p: string): Promise<boolean> => {
1893
+ try {
1894
+ const fs = await ctx.getFilesystem(kb.defaultBranch);
1895
+ if (!(await ownGuideReadable(fs, kb.defaultBranch, ctx, p))) return false;
1896
+ await notifyAgentRead(agentAccessGate, ctx, kb.defaultBranch, p);
1897
+ return await isOwnEntryStill(fs, p);
1898
+ } catch {
1899
+ return false;
1900
+ }
1845
1901
  };
1846
1902
 
1847
1903
  mount({
1848
1904
  name: 'read_file',
1849
1905
  gated: true,
1850
1906
  description:
1851
- 'Read a workspace file as text. Returns `{ path, content }`. What comes back for a document, an email file, an image ' +
1852
- 'or any other binary file is the content rule\'s business (see the shared rules): text files as text, documents and ' +
1853
- 'email files as extracted text, an image as the picture itself, anything else as a one-line description. ' +
1854
- 'Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored ' +
1855
- 'for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. ' +
1856
- 'It also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: such a ref belongs to no ' +
1857
- 'workspace, so `branch` is ignored for it.',
1907
+ 'Read a workspace file as text. Returns `{ path, content, canWrite }`. Per the shared content rule: text as ' +
1908
+ 'text, documents and email as extracted text, an image as the picture, anything else as a one-line ' +
1909
+ 'description. Also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: it ' +
1910
+ 'belongs to no workspace, so `branch` is ignored and no `canWrite` comes back. `offset`/`limit` slice the ' +
1911
+ 'content (characters; bytes for a spill ref; ignored for an image): page through large files and ' +
1912
+ 'spills, never read multi-MB whole. `canWrite`: may you change this file directly on the default branch. ' +
1913
+ CAN_WRITE_CLAUSE,
1858
1914
  inputs: {
1859
1915
  type: 'object',
1860
1916
  properties: {
@@ -1869,7 +1925,16 @@ export function registerWorkspaceTools(
1869
1925
  },
1870
1926
  outputs: {
1871
1927
  type: 'object',
1872
- properties: { path: str('The path that was read (echoes the input).'), content: str('File (or spill) content, sliced if offset/limit were given.') },
1928
+ properties: {
1929
+ path: str('The path that was read (echoes the input).'),
1930
+ content: str('File (or spill) content, sliced if offset/limit were given.'),
1931
+ canWrite: {
1932
+ type: 'boolean',
1933
+ description:
1934
+ 'Whether you may commit a change to this file directly on the default branch, under that branch\'s access ' +
1935
+ 'rules — the same answer whichever branch you read. Absent for a spill ref.',
1936
+ },
1937
+ },
1873
1938
  required: ['path', 'content'],
1874
1939
  },
1875
1940
  write: false,
@@ -1886,7 +1951,19 @@ export function registerWorkspaceTools(
1886
1951
  ? content.slice(start, limit !== undefined ? start + limit : undefined)
1887
1952
  : content;
1888
1953
  };
1889
- const result = await readForTool(a.branch as string, p, ctx);
1954
+ const branch = a.branch as string;
1955
+ const { result, guide } = await readServed(branch, p, ctx);
1956
+ // Judged after the read, so a path the caller may not read is refused
1957
+ // before anything is said about writing it. At the guide's name this
1958
+ // answers for the knowledge base's own file there, as `file_stat` does:
1959
+ // where the platform's guide stands alone there is nothing to change —
1960
+ // `false`, whether nothing is there or a file the caller may not read.
1961
+ // Like every verdict here, that is decided on the DEFAULT branch, not
1962
+ // the one read: a draft that deleted or added the file must not move
1963
+ // the answer about what can land directly.
1964
+ const ownOnDefault =
1965
+ guide === null || (branch === kb.defaultBranch ? guide === 'own' : await ownGuideOnDefault(ctx, p));
1966
+ const canWrite = ownOnDefault && (await canWriteAt(ctx, [p])).get(p) === true;
1890
1967
  // Images return the picture itself as an MCP image content block, so a
1891
1968
  // multimodal model SEES it. The handler returns the `McpImageResult`
1892
1969
  // sentinel; the MCP result shaping (`toCallToolResult` in
@@ -1898,14 +1975,17 @@ export function registerWorkspaceTools(
1898
1975
  // content blocks before any client could try to validate it, so the
1899
1976
  // schema keeps describing the text path it has always described.
1900
1977
  // `offset`/`limit` are meaningless on a picture and are ignored.
1978
+ // The picture's answer has no fields beside it, so its `canWrite` rides
1979
+ // in the text note that accompanies it.
1901
1980
  if (result.kind === 'image') {
1902
- return mcpImageResult(result.data, result.mimeType, result.note);
1981
+ const verdict = `canWrite: ${canWrite}`;
1982
+ return mcpImageResult(result.data, result.mimeType, result.note ? `${result.note} (${verdict})` : verdict);
1903
1983
  }
1904
1984
  // Text and refusals alike land in `content` — a refusal (corrupt
1905
1985
  // document, unreadable binary, oversized image) IS the file's honest
1906
1986
  // textual answer, sliced like any other content.
1907
1987
  const content = result.kind === 'text' ? result.text : result.message;
1908
- return { path: p, content: slice(content) };
1988
+ return { path: p, content: slice(content), canWrite };
1909
1989
  },
1910
1990
  });
1911
1991
 
@@ -1925,10 +2005,14 @@ export function registerWorkspaceTools(
1925
2005
  * node is indistinguishable from an absent one on every other read.
1926
2006
  */
1927
2007
  /** What a read of the guide's path answers: the guide, after the knowledge base's own readable file when it has one. */
1928
- const guideAt = async (branch: string, ctx: ToolContext, p: string): Promise<string> => {
2008
+ const guideAt = async (branch: string, ctx: ToolContext, p: string): Promise<string> =>
2009
+ (await guideServed(branch, ctx, p)).text;
2010
+
2011
+ /** {@link guideAt}, also saying whether a file of the knowledge base's own came first. */
2012
+ const guideServed = async (branch: string, ctx: ToolContext, p: string): Promise<{ text: string; own: boolean }> => {
1929
2013
  const guide = await agentGuide!();
1930
2014
  const own = await ownGuideFile(branch, ctx, p);
1931
- return own === null ? guide : withPlatformGuideAppended(own, guide);
2015
+ return own === null ? { text: guide, own: false } : { text: withPlatformGuideAppended(own, guide), own: true };
1932
2016
  };
1933
2017
 
1934
2018
  const ownGuideFile = async (branch: string, ctx: ToolContext, p: string): Promise<string | null> => {
@@ -2008,7 +2092,8 @@ export function registerWorkspaceTools(
2008
2092
  name: 'list_files',
2009
2093
  gated: true,
2010
2094
  description:
2011
- `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.`,
2095
+ `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. ` +
2096
+ `\`canWrite\`: may you change that file, or add a file directly inside that folder, directly on the default branch. ${CAN_WRITE_CLAUSE}`,
2012
2097
  inputs: {
2013
2098
  type: 'object',
2014
2099
  properties: {
@@ -2028,8 +2113,19 @@ export function registerWorkspaceTools(
2028
2113
  description: 'Directory entries.',
2029
2114
  items: {
2030
2115
  type: 'object',
2031
- properties: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes (files only).') },
2032
- required: ['name', 'type'],
2116
+ properties: {
2117
+ name: str('Entry name.'),
2118
+ type: str('`file` or `directory`.'),
2119
+ size: int('Size in bytes (files only).'),
2120
+ canWrite: {
2121
+ type: 'boolean',
2122
+ description:
2123
+ 'Whether you may commit directly on the default branch, under that branch\'s access rules: for a ' +
2124
+ 'file, a change to it; for a folder, a new file directly inside it, by that folder\'s own rules. ' +
2125
+ 'The same answer whichever branch you listed.',
2126
+ },
2127
+ },
2128
+ required: ['name', 'type', 'canWrite'],
2033
2129
  },
2034
2130
  },
2035
2131
  },
@@ -2042,7 +2138,12 @@ export function registerWorkspaceTools(
2042
2138
  const fs = await ctx.getFilesystem(a.branch as string);
2043
2139
  const entries = withoutPlaceholder((await fs.readdir(dir || '.')) as DirEntry[]);
2044
2140
  const filtered = await filterReadableEntries(readGateFor(a.branch as string, ctx), dir, entries);
2045
- return { path: a.path ?? '', entries: filtered };
2141
+ // One batch for the listing. A folder is judged at its own path, whose
2142
+ // rules (its `access.md` and those above it) are what a file added
2143
+ // directly inside it would get.
2144
+ const wsPathOf = (e: DirEntry): string => (dir ? `${dir}/${e.name}` : e.name);
2145
+ const verdicts = await canWriteAt(ctx, filtered.map(wsPathOf));
2146
+ return { path: a.path ?? '', entries: filtered.map((e) => ({ ...e, canWrite: verdicts.get(wsPathOf(e)) === true })) };
2046
2147
  },
2047
2148
  });
2048
2149
 
@@ -2285,171 +2386,172 @@ export function registerWorkspaceTools(
2285
2386
  },
2286
2387
  });
2287
2388
 
2288
- mount({
2289
- name: 'grep',
2290
- gated: true,
2291
- description:
2292
- '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).',
2293
- inputs: {
2294
- type: 'object',
2295
- properties: {
2296
- branch: BRANCH_INPUT,
2297
- pattern: str('JavaScript regular expression.'),
2298
- 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}/\`).`),
2299
- ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
2300
- max_results: { type: 'integer', minimum: 1, maximum: 1000, description: 'Cap on matches (default 200).' },
2301
- sessionId: SESSION_ID_INPUT,
2389
+ // Mounted only when the composition supplied a matcher — see the
2390
+ // `patternMatcher` parameter. Core always does.
2391
+ if (patternMatcher) {
2392
+ const patterns = patternMatcher;
2393
+ mount({
2394
+ name: 'grep',
2395
+ gated: true,
2396
+ description:
2397
+ '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).',
2398
+ inputs: {
2399
+ type: 'object',
2400
+ properties: {
2401
+ branch: BRANCH_INPUT,
2402
+ pattern: str('JavaScript regular expression.'),
2403
+ 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}/\`).`),
2404
+ ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
2405
+ max_results: { type: 'integer', minimum: 1, maximum: 1000, description: 'Cap on matches (default 200).' },
2406
+ sessionId: SESSION_ID_INPUT,
2407
+ },
2408
+ required: ['branch', 'pattern'],
2409
+ additionalProperties: false,
2302
2410
  },
2303
- required: ['branch', 'pattern'],
2304
- additionalProperties: false,
2305
- },
2306
- outputs: {
2307
- type: 'object',
2308
- properties: {
2309
- matches: {
2310
- type: 'array',
2311
- description: 'Matching lines (capped by `max_results`).',
2312
- items: {
2313
- type: 'object',
2314
- properties: { path: str('Workspace-relative file path.'), line: int('1-based line number.'), text: str('The matching line (truncated to 300 chars).') },
2315
- required: ['path', 'line', 'text'],
2411
+ outputs: {
2412
+ type: 'object',
2413
+ properties: {
2414
+ matches: {
2415
+ type: 'array',
2416
+ description: 'Matching lines (capped by `max_results`).',
2417
+ items: {
2418
+ type: 'object',
2419
+ properties: { path: str('Workspace-relative file path.'), line: int('1-based line number.'), text: str('The matching line (truncated to 300 chars).') },
2420
+ required: ['path', 'line', 'text'],
2421
+ },
2316
2422
  },
2423
+ truncated: { type: 'boolean', description: 'True if the match cap was hit and results may be incomplete.' },
2424
+ 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.'),
2317
2425
  },
2318
- truncated: { type: 'boolean', description: 'True if the match cap was hit and results may be incomplete.' },
2319
- 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.'),
2426
+ required: ['matches', 'truncated'],
2320
2427
  },
2321
- required: ['matches', 'truncated'],
2322
- },
2323
- write: false,
2324
- handler: async (a, ctx: ToolContext) => {
2325
- let re: RegExp;
2326
- try {
2327
- re = new RegExp(a.pattern as string, a.ignore_case ? 'i' : '');
2328
- } catch (err) {
2329
- throw new ToolError(`Invalid regex: ${(err as Error).message}`, 400);
2330
- }
2331
- // No path means the whole REPOSITORY, not the workspace directory above
2332
- // it. The workspace root also holds whatever an older build left beside
2333
- // the checkout, and the read gate has no rules for a path outside the
2334
- // repository — it answers "readable" — so a walk from there would hand
2335
- // any caller the contents of every stray, including documents that were
2336
- // uploaded to a restricted folder and landed beside it instead. The
2337
- // boot note names those for an operator; no tool reads them.
2338
- // An EMPTY string is the same absence: the normaliser leaves it alone
2339
- // (an empty path is the handler's to explain), and here it would
2340
- // otherwise name the workspace directory by another spelling.
2341
- const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
2342
- const fs = await ctx.getFilesystem(a.branch as string);
2343
- const gate = readGateFor(a.branch as string, ctx);
2344
- const out: { path: string; line: number; text: string }[] = [];
2345
- const max = typeof a.max_results === 'number' ? Math.min(a.max_results, 1000) : 200;
2346
- const docs: DocGrepState = { readers, uncachedBudget: UNCACHED_DOCS_PER_GREP, skippedUncached: 0 };
2347
- // The empty root is the workspace itself — always a directory, and never
2348
- // worth a stat.
2349
- // A placeholder named on its own is searched as what it is to every
2350
- // other tool: nothing.
2351
- const kind =
2352
- searchRoot === ''
2353
- ? 'directory'
2354
- : isFolderPlaceholder(searchRoot)
2355
- ? 'missing'
2356
- : await searchRootKind(fs, searchRoot);
2357
- /** Why a single-file search found nothing, when "no matches" would be a lie. */
2358
- let fileNote: string | undefined;
2359
- // The guide is searched where it is read: a search of the repository
2360
- // root covers it, and a search of its own path is a search of what
2361
- // `read_file` answers there. The composed text is what is searched —
2362
- // the knowledge base's own readable file first, then the platform's
2363
- // guide — under the guide's path and with the line numbers a read of
2364
- // it gives. The walk leaves that one file to this: a match the walk
2365
- // made there would be the same line again, and one it COUNTED against
2366
- // `max_results` would be a file later in the tree never searched while
2367
- // the answer says nothing was cut. A file the caller may not read is
2368
- // absent from it, as it is from the read.
2369
- const guidePath = `${kbDirName}/${AGENT_GUIDE_FILE}`;
2370
- const rel = toKbRelative(searchRoot, kbDirName);
2371
- const coversGuide =
2372
- agentGuide !== undefined && (kind === 'directory' ? rel === null : isAgentGuidePath(rel ?? ''));
2373
- // The search root goes to the read hook — once, and only when it is
2374
- // repository content: a folder, or a file of the knowledge base's own.
2375
- // The guide's own path is not told here, because `guideAt` tells the
2376
- // hook of the organisation's file there exactly as `read_file` does
2377
- // (after the gate), and the platform's guide alone is nobody's file to
2378
- // note. Each file the walk opens goes to the hook per file below, so a
2379
- // hook sees every path a grep reached rather than only its root.
2380
- if (!(coversGuide && kind !== 'directory')) {
2381
- await notifyAgentRead(agentAccessGate, ctx, a.branch as string, searchRoot);
2382
- }
2383
- if (coversGuide) {
2384
- if (kind === 'directory') {
2428
+ write: false,
2429
+ handler: async (a, ctx: ToolContext) => {
2430
+ // The caller's pattern never runs on the request thread: a backtracking
2431
+ // one would hold the event loop for everyone (see IPatternMatcher).
2432
+ const matcher = patterns.search(a.pattern as string, a.ignore_case ? 'i' : '');
2433
+ // No path means the whole REPOSITORY, not the workspace directory above
2434
+ // it. The workspace root also holds whatever an older build left beside
2435
+ // the checkout, and the read gate has no rules for a path outside the
2436
+ // repository — it answers "readable" — so a walk from there would hand
2437
+ // any caller the contents of every stray, including documents that were
2438
+ // uploaded to a restricted folder and landed beside it instead. The
2439
+ // boot note names those for an operator; no tool reads them.
2440
+ // An EMPTY string is the same absence: the normaliser leaves it alone
2441
+ // (an empty path is the handler's to explain), and here it would
2442
+ // otherwise name the workspace directory by another spelling.
2443
+ const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
2444
+ const fs = await ctx.getFilesystem(a.branch as string);
2445
+ const gate = readGateFor(a.branch as string, ctx);
2446
+ const out: { path: string; line: number; text: string }[] = [];
2447
+ const max = typeof a.max_results === 'number' ? Math.min(a.max_results, 1000) : 200;
2448
+ const docs: DocGrepState = { readers, uncachedBudget: UNCACHED_DOCS_PER_GREP, skippedUncached: 0 };
2449
+ // The empty root is the workspace itself — always a directory, and never
2450
+ // worth a stat.
2451
+ // A placeholder named on its own is searched as what it is to every
2452
+ // other tool: nothing.
2453
+ const kind =
2454
+ searchRoot === ''
2455
+ ? 'directory'
2456
+ : isFolderPlaceholder(searchRoot)
2457
+ ? 'missing'
2458
+ : await searchRootKind(fs, searchRoot);
2459
+ /** Why a single-file search found nothing, when "no matches" would be a lie. */
2460
+ let fileNote: string | undefined;
2461
+ // The guide is searched where it is read: a search of the repository
2462
+ // root covers it, and a search of its own path is a search of what
2463
+ // `read_file` answers there. The composed text is what is searched —
2464
+ // the knowledge base's own readable file first, then the platform's
2465
+ // guide — under the guide's path and with the line numbers a read of
2466
+ // it gives. The walk leaves that one file to this: a match the walk
2467
+ // made there would be the same line again, and one it COUNTED against
2468
+ // `max_results` would be a file later in the tree never searched while
2469
+ // the answer says nothing was cut. A file the caller may not read is
2470
+ // absent from it, as it is from the read.
2471
+ const guidePath = `${kbDirName}/${AGENT_GUIDE_FILE}`;
2472
+ const rel = toKbRelative(searchRoot, kbDirName);
2473
+ const coversGuide =
2474
+ agentGuide !== undefined && (kind === 'directory' ? rel === null : isAgentGuidePath(rel ?? ''));
2475
+ // The search root goes to the read hook — once, and only when it is
2476
+ // repository content: a folder, or a file of the knowledge base's own.
2477
+ // The guide's own path is not told here, because `guideAt` tells the
2478
+ // hook of the organisation's file there exactly as `read_file` does
2479
+ // (after the gate), and the platform's guide alone is nobody's file to
2480
+ // note. Each file the walk opens goes to the hook per file below, so a
2481
+ // hook sees every path a grep reached rather than only its root.
2482
+ if (!(coversGuide && kind !== 'directory')) {
2483
+ await notifyAgentRead(agentAccessGate, ctx, a.branch as string, searchRoot);
2484
+ }
2485
+ if (coversGuide) {
2486
+ if (kind === 'directory') {
2487
+ await grepWalk(
2488
+ fs,
2489
+ searchRoot,
2490
+ matcher,
2491
+ out,
2492
+ max,
2493
+ 0,
2494
+ gate,
2495
+ (p) => notifyAgentRead(agentAccessGate, ctx, a.branch as string, p),
2496
+ docs,
2497
+ (p) => p === guidePath,
2498
+ );
2499
+ }
2500
+ const lines = (await guideAt(a.branch as string, ctx, guidePath)).split('\n');
2501
+ for (const i of await matcher.matchingLines(lines, max - out.length)) {
2502
+ out.push({ path: guidePath, line: i + 1, text: lines[i]!.slice(0, 300) });
2503
+ }
2504
+ } else if (kind === 'directory') {
2385
2505
  await grepWalk(
2386
2506
  fs,
2387
2507
  searchRoot,
2388
- re,
2508
+ matcher,
2389
2509
  out,
2390
2510
  max,
2391
2511
  0,
2392
2512
  gate,
2393
2513
  (p) => notifyAgentRead(agentAccessGate, ctx, a.branch as string, p),
2394
2514
  docs,
2395
- (p) => p === guidePath,
2396
2515
  );
2516
+ } else {
2517
+ // Not a directory: the permission verdict comes BEFORE every other
2518
+ // one, and it is `read_file`'s own gate on the same path — so grep
2519
+ // answers a path the caller may not read exactly as read_file does,
2520
+ // and can never confirm the existence of one read_file would hide.
2521
+ // That ordering holds even when the stat itself failed: a denied path
2522
+ // gets the 403, never the filesystem's complaint about it.
2523
+ await assertCanRead(gate, searchRoot);
2524
+ if (kind === 'missing') throw notFound(searchRoot, 'Nothing to search');
2525
+ // A named FILE is searched directly: routing it through the walk would
2526
+ // fail its readdir and answer an empty match list, which the caller
2527
+ // cannot tell from "the pattern is not in this file".
2528
+ const outcome = await orNotFound(
2529
+ searchRoot,
2530
+ () => grepOneFile(fs, searchRoot, matcher, out, max, docs),
2531
+ 'Nothing to search',
2532
+ );
2533
+ if (outcome === 'no-text') {
2534
+ fileNote =
2535
+ `"${displayPath(searchRoot)}" has no searchable text — it is an image, binary content, or a document ` +
2536
+ 'whose text could not be extracted. There were no matches because there was nothing to search, not ' +
2537
+ 'because the pattern is absent.';
2538
+ }
2397
2539
  }
2398
- const lines = (await guideAt(a.branch as string, ctx, guidePath)).split('\n');
2399
- for (let i = 0; i < lines.length && out.length < max; i++) {
2400
- re.lastIndex = 0;
2401
- if (re.test(lines[i]!)) out.push({ path: guidePath, line: i + 1, text: lines[i]!.slice(0, 300) });
2402
- }
2403
- } else if (kind === 'directory') {
2404
- await grepWalk(
2405
- fs,
2406
- searchRoot,
2407
- re,
2408
- out,
2409
- max,
2410
- 0,
2411
- gate,
2412
- (p) => notifyAgentRead(agentAccessGate, ctx, a.branch as string, p),
2413
- docs,
2414
- );
2415
- } else {
2416
- // Not a directory: the permission verdict comes BEFORE every other
2417
- // one, and it is `read_file`'s own gate on the same path — so grep
2418
- // answers a path the caller may not read exactly as read_file does,
2419
- // and can never confirm the existence of one read_file would hide.
2420
- // That ordering holds even when the stat itself failed: a denied path
2421
- // gets the 403, never the filesystem's complaint about it.
2422
- await assertCanRead(gate, searchRoot);
2423
- if (kind === 'missing') throw notFound(searchRoot, 'Nothing to search');
2424
- // A named FILE is searched directly: routing it through the walk would
2425
- // fail its readdir and answer an empty match list, which the caller
2426
- // cannot tell from "the pattern is not in this file".
2427
- const outcome = await orNotFound(
2428
- searchRoot,
2429
- () => grepOneFile(fs, searchRoot, re, out, max, docs),
2430
- 'Nothing to search',
2431
- );
2432
- if (outcome === 'no-text') {
2433
- fileNote =
2434
- `"${displayPath(searchRoot)}" has no searchable text — it is an image, binary content, or a document ` +
2435
- 'whose text could not be extracted. There were no matches because there was nothing to search, not ' +
2436
- 'because the pattern is absent.';
2437
- }
2438
- }
2439
- const note =
2440
- fileNote ??
2441
- (docs.skippedUncached > 0
2442
- ? `${docs.skippedUncached} document(s) (office/PDF/email files) were not searched: their text was not yet ` +
2443
- `extracted and this call's extraction budget (${UNCACHED_DOCS_PER_GREP}) ran out. Re-run the ` +
2444
- 'same grep to extract and search the next batch.'
2445
- : undefined);
2446
- return {
2447
- matches: out,
2448
- truncated: out.length >= max,
2449
- ...(note !== undefined ? { note } : {}),
2450
- };
2451
- },
2452
- });
2540
+ const note =
2541
+ fileNote ??
2542
+ (docs.skippedUncached > 0
2543
+ ? `${docs.skippedUncached} document(s) (office/PDF/email files) were not searched: their text was not yet ` +
2544
+ `extracted and this call's extraction budget (${UNCACHED_DOCS_PER_GREP}) ran out. Re-run the ` +
2545
+ 'same grep to extract and search the next batch.'
2546
+ : undefined);
2547
+ return {
2548
+ matches: out,
2549
+ truncated: out.length >= max,
2550
+ ...(note !== undefined ? { note } : {}),
2551
+ };
2552
+ },
2553
+ });
2554
+ }
2453
2555
 
2454
2556
  // ── writes (through the lock/commit pipeline) ───────────────────────────
2455
2557
  mount({
@@ -3965,194 +4067,6 @@ export function registerWorkspaceTools(
3965
4067
  });
3966
4068
  }
3967
4069
 
3968
- // ── shell (internal-only) ───────────────────────────────────────────────
3969
- mount({
3970
- name: 'execute_command',
3971
- gated: true,
3972
- description:
3973
- 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.',
3974
- internalOnly: true,
3975
- fileTool: false,
3976
- // The one tool the mount's branch check skips: the handler below resolves an
3977
- // omitted `branch` to the internal caller's focused branch before refusing.
3978
- resolvesBranchItself: true,
3979
- inputs: {
3980
- type: 'object',
3981
- properties: {
3982
- branch: BRANCH_INPUT,
3983
- command: str('The shell command line to run.'),
3984
- timeout_ms: { type: 'integer', minimum: 1000, maximum: 120000, description: 'Timeout in ms (default 30000).' },
3985
- sessionId: SESSION_ID_INPUT,
3986
- },
3987
- // `branch` stays REQUIRED here on purpose, and must not be relaxed to make
3988
- // the handler's focused-branch fallback "reachable". This list is the
3989
- // contract the model is TAUGHT — declaring it required is what makes the
3990
- // caller always name its branch, which is the fix for the workspace this
3991
- // tool used to resolve as "undefined". It is not a runtime gate: nothing in
3992
- // the tool route validates inputs against this schema, so a branch-less call
3993
- // still reaches the handler and still hits the `ctx.focusedBranch` fallback
3994
- // (covered by "falls back to the session focused branch when branch is
3995
- // omitted"). The fallback is a safety net for a model that disobeys the
3996
- // contract — not a sanctioned calling convention to advertise.
3997
- required: ['branch', 'command'],
3998
- additionalProperties: false,
3999
- },
4000
- outputs: {
4001
- type: 'object',
4002
- properties: {
4003
- stdout: str('Captured standard output (truncated to 50,000 chars).'),
4004
- stderr: str('Captured standard error (truncated to 50,000 chars).'),
4005
- exitCode: int('Process exit code; -1 if it was killed (timeout/abort) or failed to spawn.'),
4006
- },
4007
- required: ['stdout', 'stderr', 'exitCode'],
4008
- },
4009
- write: true,
4010
- handler: async (a, ctx: ToolContext) => {
4011
- // Resolve the branch FIRST — before the policy gates below — so a malformed
4012
- // call always gets the 400 that names what is missing, rather than a 403
4013
- // from a restricted session masking it. Unlike the file tools (which route
4014
- // through `getOrCreateForUser` and fall back to the default branch),
4015
- // execute_command turns `branch` straight into a workspace — so a bad value
4016
- // would `encodeURIComponent(undefined)` to the string "undefined", then try
4017
- // to CLONE a branch literally named "undefined", 500ing and leaving an
4018
- // `<workspaces>/undefined/` shell behind.
4019
- //
4020
- // Two distinct cases:
4021
- // - branch OMITTED (`undefined`): the in-app chat agent may leave it off a
4022
- // call. For an internal session we fall back to the caller's own focused
4023
- // branch (`ctx.focusedBranch`, from its signed token), so the command runs
4024
- // against the workspace the session is on (e.g. `main`) end to end. An
4025
- // external caller carries no focused branch, so it stays absent → 400.
4026
- // - branch PRESENT but invalid (the literal "undefined"/"null", empty, or
4027
- // malformed): a broken value, never a real branch — fail closed, never
4028
- // silently reinterpret it as the focused branch.
4029
- const raw = a.branch;
4030
- const branch = raw === undefined ? ctx.focusedBranch : raw;
4031
- if (typeof branch !== 'string' || branch.length === 0) {
4032
- throw new ToolError(
4033
- 'execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.',
4034
- 400,
4035
- // The same discriminator every other KB tool answers a branch-less
4036
- // call with, so a client switches on one kind across the surface.
4037
- // The MESSAGE stays this tool's own: it can name the focused-branch
4038
- // fallback that only applies here.
4039
- { kind: 'branch-required' },
4040
- );
4041
- }
4042
- // A stringified absent value. Both are syntactically valid git branch names,
4043
- // so `assertValidBranchName` below happily accepts them — and accepting one
4044
- // is the whole bug this tool is guarded for: `getOrCreateForBranch("undefined")`
4045
- // clones a branch literally named "undefined" into `<workspaces>/undefined/`
4046
- // and 500s. Reject them by name, ahead of the shape check.
4047
- if (branch === 'undefined' || branch === 'null') {
4048
- throw new ToolError(
4049
- `execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
4050
- 'Pass the real branch (draft) whose workspace to run the command in.',
4051
- 400,
4052
- { kind: 'branch-required' },
4053
- );
4054
- }
4055
- // Then the SHAPE, via the one canonical validator every other branch path
4056
- // uses — no hand-maintained list of suspicious literals, which would both
4057
- // miss malformed refs (`..`, `-x`, `foo/.lock`) and reserve names git
4058
- // considers perfectly valid. Runs on the RAW string, so a whitespace-padded
4059
- // `" main "` is refused rather than silently trimmed into a different
4060
- // branch than the caller passed.
4061
- try {
4062
- assertValidBranchName(branch);
4063
- } catch (err) {
4064
- throw new ToolError(
4065
- `execute_command got an invalid \`branch\`: ${(err as Error).message} — ` +
4066
- 'pass the exact branch (draft) whose workspace to run the command in.',
4067
- 400,
4068
- );
4069
- }
4070
- // Shell is a write path with no single target path to check, so the
4071
- // write hook is asked once for the call itself, with no path — and the
4072
- // run must not be restricted to a file type either, since shell could
4073
- // write anything.
4074
- writePolicy.assertUnrestricted(ctx.sessionId);
4075
- await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
4076
- // Canonical per-branch bootstrap entry point — it owns the workspace-id
4077
- // encoding and the single-flight clone, so the shell never derives a
4078
- // workspace path by hand.
4079
- const cwd = (await ctx.workspaceService.getOrCreateForBranch(branch)).absolutePath;
4080
- const timeoutMs = typeof a.timeout_ms === 'number' ? a.timeout_ms : 30_000;
4081
- // cwd is the workspace ROOT so shell paths match the workspace-relative
4082
- // paths every other file tool uses — but the git clone lives one level
4083
- // deeper, at <workspace>/<kbDirName>/.git. For a bare `git status` /
4084
- // `git diff` (what the agent prompt teaches) point git there explicitly so
4085
- // it resolves the KB clone instead of failing with "not a git repository".
4086
- //
4087
- // Because the command runs under `shell: true`, GIT_DIR/GIT_WORK_TREE
4088
- // apply to the WHOLE shell, so restrict the override to a *standalone* bare
4089
- // `git …` invocation. Otherwise the KB repo context would (a) leak into a
4090
- // chained step that spawns its own git — `git commit && npm ci` — and (b)
4091
- // override a caller's explicit `git -C …` / `--git-dir` / `--work-tree`.
4092
- const command = (a.command as string).trim();
4093
- const isStandaloneGit =
4094
- /^git(\s|$)/.test(command) &&
4095
- !/[;&|`\n]|\$\(/.test(command) &&
4096
- !/(?:^|\s)(?:-C|--git-dir|--work-tree)(?:[=\s]|$)/.test(command);
4097
- const env = isStandaloneGit
4098
- ? {
4099
- ...process.env,
4100
- GIT_DIR: join(cwd, kbDirName, '.git'),
4101
- GIT_WORK_TREE: join(cwd, kbDirName),
4102
- }
4103
- : process.env;
4104
- return new Promise((resolve) => {
4105
- // Own process group on POSIX, so a timeout or abort kills the WHOLE
4106
- // tree. `shell: true` puts an `sh` between us and the command; killing
4107
- // only that shell left whatever it had started running on — orphaned,
4108
- // unreaped, still holding the workspace and its memory. A self-hosted
4109
- // deployment leaked ~4,500 tasks that way before nothing could fork.
4110
- const detached = process.platform !== 'win32';
4111
- const child = spawn(a.command as string, { cwd, shell: true, env, detached });
4112
- let stdout = '';
4113
- let stderr = '';
4114
- const killTree = () => {
4115
- try {
4116
- if (detached && child.pid) process.kill(-child.pid, 'SIGKILL');
4117
- else child.kill('SIGKILL');
4118
- } catch {
4119
- try {
4120
- child.kill('SIGKILL');
4121
- } catch {
4122
- // Already gone.
4123
- }
4124
- }
4125
- };
4126
- const timer = setTimeout(killTree, timeoutMs);
4127
- // If the caller disconnects (request aborted), kill the tree instead of
4128
- // letting it run to the timeout; the resulting `close`/`error` settles
4129
- // the promise through `finish`.
4130
- const onAbort = killTree;
4131
- ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
4132
- const finish = (value: unknown) => {
4133
- clearTimeout(timer);
4134
- ctx.abortSignal.removeEventListener('abort', onAbort);
4135
- resolve(value);
4136
- };
4137
- // Cap accumulation while streaming so a verbose command can't exhaust
4138
- // memory before `close`; the final slice keeps the output bound.
4139
- const OUTPUT_CAP = 50_000;
4140
- child.stdout.on('data', (d) => {
4141
- if (stdout.length < OUTPUT_CAP) stdout += d.toString();
4142
- });
4143
- child.stderr.on('data', (d) => {
4144
- if (stderr.length < OUTPUT_CAP) stderr += d.toString();
4145
- });
4146
- child.on('close', (code) => {
4147
- finish({ stdout: stdout.slice(0, 50_000), stderr: stderr.slice(0, 50_000), exitCode: code ?? -1 });
4148
- });
4149
- child.on('error', (err) => {
4150
- finish({ stdout, stderr: `${stderr}\n${String(err)}`.slice(0, 50_000), exitCode: -1 });
4151
- });
4152
- });
4153
- },
4154
- });
4155
-
4156
4070
  // The one read `open_page` borrows — see `readForTool` above.
4157
4071
  return { readForTool };
4158
4072
  }