@bevel-software/platform-core-backend 0.7.5 → 0.8.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 (230) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +30 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  104. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  105. package/dist/modules/workspace/kb-seed.service.js +43 -10
  106. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  107. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  108. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  109. package/dist/modules/workspace/plugins-migration.js +379 -0
  110. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  111. package/dist/modules/workspace/workspace.routes.js +3 -3
  112. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  113. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  114. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  115. package/dist/modules/workspace/workspace.service.js +83 -12
  116. package/dist/modules/workspace/workspace.service.js.map +1 -1
  117. package/dist/shared/kb-layout.test.js +3 -3
  118. package/dist/shared/kb-layout.test.js.map +1 -1
  119. package/dist/shared/utcp-namespace.d.ts +6 -27
  120. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  121. package/dist/shared/utcp-namespace.js +6 -63
  122. package/dist/shared/utcp-namespace.js.map +1 -1
  123. package/dist/shared/variable-refs.d.ts +42 -0
  124. package/dist/shared/variable-refs.d.ts.map +1 -0
  125. package/dist/shared/variable-refs.js +60 -0
  126. package/dist/shared/variable-refs.js.map +1 -0
  127. package/kb-template/.bevelignore +1 -1
  128. package/kb-template/AGENTS.md +88 -35
  129. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  130. package/kb-template/access.md +36 -36
  131. package/migrations/meta/0000_snapshot.json +1479 -1479
  132. package/package.json +5 -4
  133. package/src/assets.ts +25 -25
  134. package/src/core/core-ports.ts +106 -106
  135. package/src/core/create-core-server.ts +55 -9
  136. package/src/core/create-core-services.ts +40 -20
  137. package/src/index.ts +69 -69
  138. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  139. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  140. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  141. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  142. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  143. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  144. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  145. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  146. package/src/modules/access/access-control.interface.ts +8 -7
  147. package/src/modules/access/access-control.service.ts +7 -7
  148. package/src/modules/access/access-declarations.ts +5 -5
  149. package/src/modules/access/access-mutation.service.ts +3 -3
  150. package/src/modules/access/access-splice.ts +4 -4
  151. package/src/modules/access/access.routes.ts +20 -20
  152. package/src/modules/access/creator-access.ts +5 -5
  153. package/src/modules/access/roles-admin.service.ts +13 -2
  154. package/src/modules/admin/admin-access.routes.ts +29 -29
  155. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  156. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  157. package/src/modules/auth/rate-limit.ts +45 -45
  158. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  159. package/src/modules/code-mode/code-mode-names.ts +10 -36
  160. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  161. package/src/modules/database/connection.ts +15 -15
  162. package/src/modules/database/schema.ts +11 -11
  163. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  164. package/src/modules/mcp/mcp.service.ts +57 -435
  165. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  166. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  167. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  168. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  169. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  170. package/src/modules/plugins/index.ts +17 -0
  171. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  172. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  173. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  174. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  175. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  176. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  177. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  178. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  179. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  180. package/src/modules/skills/pending-skills.service.ts +7 -7
  181. package/src/modules/skills/skills.contract.ts +4 -4
  182. package/src/modules/skills/skills.service.ts +5 -5
  183. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  184. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  185. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  186. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  187. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  188. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  189. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  190. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  191. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  192. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  193. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  194. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  195. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  196. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  197. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  198. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  199. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  200. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  201. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  202. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  203. package/src/modules/workflow/git/git.service.ts +2 -2
  204. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  205. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  206. package/src/modules/workflow/workflow-hooks.ts +101 -101
  207. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  208. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  209. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  210. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  211. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  212. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  213. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  214. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  215. package/src/modules/workspace/bevel-ignore.ts +66 -66
  216. package/src/modules/workspace/kb-seed.service.ts +38 -9
  217. package/src/modules/workspace/plugins-migration.ts +479 -0
  218. package/src/modules/workspace/session-sink.ts +25 -25
  219. package/src/modules/workspace/workspace.routes.ts +3 -3
  220. package/src/modules/workspace/workspace.service.ts +85 -14
  221. package/src/modules/workspace/workspace.tools.ts +922 -922
  222. package/src/shared/__tests__/join-request.test.ts +13 -13
  223. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  224. package/src/shared/kb-layout.test.ts +3 -3
  225. package/src/shared/utcp-namespace.ts +10 -68
  226. package/src/shared/variable-refs.ts +64 -0
  227. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  228. package/src/modules/groups/index.ts +0 -17
  229. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  230. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -1,922 +1,922 @@
1
- import { spawn } from 'node:child_process';
2
- import { join } from 'node:path';
3
- import type { Router, RequestHandler } from 'express';
4
- import type { LocalFilesystem } from '@mastra/core/workspace';
5
- import type { IToolRegistry, JsonSchema } from '../tool-registry/tool.contract.js';
6
- import { ToolError, type ToolContext, type ToolHandler } from '../tool-helpers/tool.contract.js';
7
- import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
8
- import {
9
- recordOntologyRead,
10
- assertOntologyWriteAllowed,
11
- assertShellAllowedWithinOntology,
12
- ONTOLOGY_BOUNDARY_NOTE,
13
- SESSION_ID_INPUT,
14
- type SessionOntologyGate,
15
- } from './session-ontology.gate.js';
16
- import type { IRoutineWritePolicy } from './routine-write-policy.js';
17
- import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
18
- import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
19
- import { workspaceIdForBranch } from './workspace.service.js';
20
- // Leaf-level shared primitive (same exception `workspace.service.ts` already
21
- // relies on) — not a workflow service, so this stays inside the module boundary.
22
- import { assertValidBranchName } from '../workflow/git/branch-name.js';
23
- import type { ISessionSink } from './session-sink.js';
24
- import type { IAccessControl } from '../access/access-control.interface.js';
25
- import { toKbRelative, resolveReadableMap } from '../access/kb-read-filter.js';
26
- import type { SpillStore } from './spill-store.js';
27
-
28
- /** A directory entry as returned by `LocalFilesystem.readdir`. */
29
- interface DirEntry {
30
- name: string;
31
- type: 'file' | 'directory';
32
- size?: number;
33
- }
34
-
35
- /**
36
- * Per-call read-permission gate. Built inside each read handler from the
37
- * caller's identity (`ctx.user.email`) and the branch they targeted, then
38
- * threaded into the filesystem walk so `read_file` / `list_files` / `grep`
39
- * never surface a KB node the user may not read.
40
- */
41
- interface ReadGate {
42
- accessControl: IAccessControl;
43
- kbDirName: string;
44
- workspaceId: string;
45
- userEmail: string;
46
- }
47
-
48
- /** Throw a 403 ToolError if the gate denies reading `wsPath` (a KB node). */
49
- async function assertCanRead(gate: ReadGate, wsPath: string): Promise<void> {
50
- const rel = toKbRelative(wsPath, gate.kbDirName);
51
- if (rel === null) return; // not a KB node — not governed by read rules
52
- const ok = await gate.accessControl.canRead(gate.workspaceId, gate.userEmail, rel);
53
- if (!ok) throw new ToolError(`You don't have permission to read "${wsPath}".`, 403);
54
- }
55
-
56
- /**
57
- * Drop the file entries the caller may not read from a directory listing.
58
- * Directory entries and non-KB files are always kept (a directory itself has
59
- * no `read:` verdict; its restricted children are filtered when read/listed) —
60
- * this is the AGENT's inclusion policy and differs from the explorer tree,
61
- * which hides restricted directories outright. Uses the FULL `canReadBatch`
62
- * (per-node frontmatter honoured), via the shared `resolveReadableMap`. One
63
- * batched ACL load per directory.
64
- */
65
- async function filterReadableEntries(
66
- gate: ReadGate,
67
- dir: string,
68
- entries: DirEntry[],
69
- ): Promise<DirEntry[]> {
70
- const fileWsPaths: string[] = [];
71
- for (const e of entries) {
72
- if (e.type === 'directory') continue;
73
- fileWsPaths.push(dir ? `${dir}/${e.name}` : e.name);
74
- }
75
- if (fileWsPaths.length === 0) return entries;
76
- const verdict = await resolveReadableMap(
77
- (wid, email, rels) => gate.accessControl.canReadBatch(wid, email, rels),
78
- gate.workspaceId,
79
- gate.userEmail,
80
- gate.kbDirName,
81
- fileWsPaths,
82
- );
83
- return entries.filter((e) => {
84
- if (e.type === 'directory') return true;
85
- const wsPath = dir ? `${dir}/${e.name}` : e.name;
86
- return verdict.get(wsPath) === true;
87
- });
88
- }
89
-
90
- /**
91
- * Appended (centrally, in `mount`) to EVERY workspace tool description. A KB
92
- * author can drop an `AGENTS.md` at the workspace root to document conventions
93
- * for that knowledge base; agents (ours and external) should consult it before
94
- * touching files. It rides on every entrypoint — reads (grep/list_files/
95
- * file_stat) included — because any of them can be a session's first touch.
96
- *
97
- * `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
98
- * rename still carry one, and the seeder never deletes a file it did not
99
- * expect. Naming both means an agent finds the conventions either way, instead
100
- * of reading none because it looked for the newer name and stopped.
101
- */
102
- const KB_CONVENTIONS_NOTE =
103
- ' Before your first read or change in a workspace, read `AGENTS.md` at the KB root — or `CLAUDE.md` on a knowledge base seeded before it was renamed — if either exists: it holds the author\'s conventions for this knowledge base, and you should follow them.';
104
-
105
- const int = (description: string): JsonSchema => ({ type: 'integer', description });
106
-
107
- const str = (description: string): JsonSchema => ({ type: 'string', description });
108
-
109
- function asText(content: string | Buffer): string {
110
- return typeof content === 'string' ? content : content.toString('utf8');
111
- }
112
-
113
- /** JS grep over the workspace tree (read methods only) — bounded by match + depth caps. */
114
- async function grepWalk(
115
- fs: LocalFilesystem,
116
- dir: string,
117
- re: RegExp,
118
- out: { path: string; line: number; text: string }[],
119
- max: number,
120
- depth: number,
121
- gate: ReadGate,
122
- recordOntologyRead: (path: string) => Promise<void>,
123
- ): Promise<void> {
124
- if (out.length >= max || depth > 12) return;
125
- let entries;
126
- try {
127
- entries = (await fs.readdir(dir || '.')) as DirEntry[];
128
- } catch {
129
- return;
130
- }
131
- // Filter unreadable KB nodes out of the walk so grep never opens — or leaks
132
- // a line from — a file the caller may not read.
133
- entries = await filterReadableEntries(gate, dir, entries);
134
- for (const e of entries) {
135
- if (out.length >= max) return;
136
- if (e.name === '.git' || e.name === 'node_modules') continue;
137
- const p = dir ? `${dir}/${e.name}` : e.name;
138
- if (e.type === 'directory') {
139
- await grepWalk(fs, p, re, out, max, depth + 1, gate, recordOntologyRead);
140
- } else {
141
- // Opening a file under a named ontology is a read of that ontology — even
142
- // for a root-level grep that resolves to a neutral root. Record it so a
143
- // cross-ontology grep poisons later writes (closes the read-leak).
144
- await recordOntologyRead(p);
145
- let content;
146
- try {
147
- content = asText(await fs.readFile(p));
148
- } catch {
149
- continue;
150
- }
151
- if (content.includes(String.fromCharCode(0))) continue; // skip binary (NUL)
152
- const lines = content.split('\n');
153
- for (let i = 0; i < lines.length && out.length < max; i++) {
154
- re.lastIndex = 0;
155
- if (re.test(lines[i])) out.push({ path: p, line: i + 1, text: lines[i].slice(0, 300) });
156
- }
157
- }
158
- }
159
- }
160
-
161
- /**
162
- * Workspace domain tools: the file primitives (replacing Mastra's auto-injected
163
- * Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
164
- * methods Mastra's tools call (via `ctx.getFilesystem(a.branch as string)`), so behaviour is
165
- * identical and write-side ops still flow through the lock/commit pipeline.
166
- * `edit_file`, `grep`, and `execute_command` are tool-level (no filesystem
167
- * method), so they're implemented here. File ops are `both`; `execute_command`
168
- * is INTERNAL-only (arbitrary shell as the caller is too dangerous to expose).
169
- */
170
- export function registerWorkspaceTools(
171
- registry: IToolRegistry,
172
- router: Router,
173
- toolAuth: RequestHandler,
174
- toolHandler: ToolHandlerFactory,
175
- spillStore: SpillStore,
176
- accessControl: IAccessControl,
177
- kbDirName: string,
178
- sessionOntologyGate: SessionOntologyGate,
179
- writePolicy: IRoutineWritePolicy,
180
- sessionSink: ISessionSink,
181
- ): void {
182
- /** Build the per-call read gate from the tool's branch input + caller identity. */
183
- const readGateFor = (branch: string, ctx: ToolContext): ReadGate => ({
184
- accessControl,
185
- kbDirName,
186
- workspaceId: workspaceIdForBranch(branch),
187
- userEmail: ctx.user.email,
188
- });
189
-
190
- const mount = (spec: {
191
- name: string;
192
- description: string;
193
- inputs: JsonSchema;
194
- outputs?: JsonSchema;
195
- write: boolean;
196
- internalOnly?: boolean;
197
- handler: ToolHandler;
198
- }): void => {
199
- const path = `/api/agent/tools/${spec.name}`;
200
- const def = toolDef({
201
- name: spec.name,
202
- // Every workspace entrypoint carries the AGENTS.md reminder, appended once
203
- // here so no tool (especially the read-only ones a session hits first) can
204
- // miss it.
205
- description: spec.description + KB_CONVENTIONS_NOTE,
206
- path,
207
- inputs: spec.inputs,
208
- outputs: spec.outputs,
209
- tags: spec.write ? ['workspace', 'write'] : ['workspace'],
210
- });
211
- registry.registerInternalTool(def);
212
- if (!spec.internalOnly) registry.registerExternalTool(def);
213
- // Internal-only tools (e.g. `execute_command`) keep their route mounted —
214
- // our agent calls it over the same loopback — but gate it to internal-source
215
- // callers so an external connection key can't invoke it by name.
216
- router.post(
217
- path.slice('/api'.length),
218
- toolAuth,
219
- ...(spec.internalOnly ? [requireInternalSource] : []),
220
- toolHandler(spec.handler, { write: spec.write }),
221
- );
222
- };
223
-
224
- // ── session bootstrap (external agents) ─────────────────────────────────
225
- // Every read/write tool below scopes the ontology-session boundary off a
226
- // `sessionId`. The in-process agent carries its thread id, but an external
227
- // agent has no ambient run id and so cannot satisfy the gate until it has
228
- // one. This mints that id up front (called ONCE); the MCP proxy then threads
229
- // it onto every later gated call via its sessionId-output continuity
230
- // convention. EXTERNAL-ONLY (not registered internal): the in-process agent
231
- // already supplies its session id and ignores any body value.
232
- //
233
- // WHAT the minted id is backed by is the `ISessionSink` port's business
234
- // (session-sink.ts). In the enterprise app it is a REAL chat-thread id, so
235
- // the SAME id works end to end: KB reads scope the ontology boundary under
236
- // it, AND `ask` accepts it (its sessionId IS a chat thread, resolved via
237
- // getThread) — that unification is what stops a caller reading from one
238
- // ontology and then having `ask` write into another. In a core-only
239
- // deployment (no chat/ask) the default sink mints a bare id, which is all
240
- // the ontology gate needs.
241
- const startSessionDef = toolDef({
242
- name: 'start_session',
243
- description:
244
- 'Mint the KnowledgeBase session id this run needs to read or write the knowledge ontologies. Call this ONCE, before any other KnowledgeBase tool, and only once per run — every gated tool needs the `sessionId` it returns to enforce the one-ontology-per-conversation boundary, and minting a new id mid-run resets that boundary. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: reads and ask then share one ontology boundary. Pass the returned id explicitly as `sessionId` on every subsequent KnowledgeBase tool call (direct MCP calls and inside `call_tool_chain` alike). Returns `{ sessionId }`.',
245
- path: '/api/agent/tools/start_session',
246
- inputs: { type: 'object', properties: {}, additionalProperties: false },
247
- outputs: {
248
- type: 'object',
249
- properties: { sessionId: str('The minted session id — pass it as `sessionId` on subsequent KnowledgeBase tool calls and to `ask`.') },
250
- required: ['sessionId'],
251
- },
252
- tags: ['workspace'],
253
- });
254
- registry.registerExternalTool(startSessionDef);
255
- // Mint the session id via the sink and return it (see comment above: one id
256
- // spans start_session -> reads -> ask, closing the ontology-pollution gap).
257
- router.post(
258
- '/agent/tools/start_session',
259
- toolAuth,
260
- // External-only: an internal token already carries its run's sessionId, so
261
- // minting a new thread mid-run would reset the ontology boundary. Note
262
- // "external" includes the MCP proxy's `externalProxy` loopback tokens
263
- // (OAuth/JWT MCP sessions) — the verifier resolves those to
264
- // `source: 'external'`, and one such session may legitimately mint several
265
- // per-chat sessionIds over its lifetime.
266
- requireExternalSource,
267
- toolHandler(async (_args, ctx) => {
268
- const { sessionId } = await sessionSink.createSession(ctx.user.id, new Date());
269
- return { sessionId };
270
- }),
271
- );
272
-
273
- // ── reads ──────────────────────────────────────────────────────────────
274
- mount({
275
- name: 'read_file',
276
- description:
277
- 'Read a workspace file as text. Returns `{ path, content }`. Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. A spill ref is workspace-independent: `branch` is ignored for it.' +
278
- ONTOLOGY_BOUNDARY_NOTE,
279
- inputs: {
280
- type: 'object',
281
- properties: {
282
- branch: BRANCH_INPUT,
283
- path: str('Workspace-relative path, or a `__tool_chain_spill__/…` ref from a truncated `call_tool_chain`.'),
284
- offset: int('Start character index (default 0).'),
285
- limit: int('Max characters to return from `offset`.'),
286
- sessionId: SESSION_ID_INPUT,
287
- },
288
- required: ['branch', 'path'],
289
- additionalProperties: false,
290
- },
291
- outputs: {
292
- type: 'object',
293
- properties: { path: str('The path that was read (echoes the input).'), content: str('File (or spill) content, sliced if offset/limit were given.') },
294
- required: ['path', 'content'],
295
- },
296
- write: false,
297
- handler: async (a, ctx: ToolContext) => {
298
- const p = a.path as string;
299
- const offset = typeof a.offset === 'number' ? a.offset : undefined;
300
- const limit = typeof a.limit === 'number' ? a.limit : undefined;
301
- if (spillStore.isSpillRef(p)) {
302
- return { path: p, content: await spillStore.read(p, offset, limit) };
303
- }
304
- await recordOntologyRead(sessionOntologyGate, ctx, p);
305
- await assertCanRead(readGateFor(a.branch as string, ctx), p);
306
- const fs = await ctx.getFilesystem(a.branch as string);
307
- const content = asText(await fs.readFile(p));
308
- const start = offset && offset > 0 ? offset : 0;
309
- const sliced = offset !== undefined || limit !== undefined
310
- ? content.slice(start, limit !== undefined ? start + limit : undefined)
311
- : content;
312
- return { path: p, content: sliced };
313
- },
314
- });
315
-
316
- mount({
317
- name: 'list_files',
318
- description:
319
- 'List a directory. Returns `{ path, entries: [{ name, type, size? }] }`. Omit `path` for the workspace root.' +
320
- ONTOLOGY_BOUNDARY_NOTE,
321
- inputs: {
322
- type: 'object',
323
- properties: {
324
- branch: BRANCH_INPUT,
325
- path: str('Workspace-relative directory (default: root).'),
326
- sessionId: SESSION_ID_INPUT,
327
- },
328
- required: ['branch'],
329
- additionalProperties: false,
330
- },
331
- outputs: {
332
- type: 'object',
333
- properties: {
334
- path: str('The directory listed (empty string for the root).'),
335
- entries: {
336
- type: 'array',
337
- description: 'Directory entries.',
338
- items: {
339
- type: 'object',
340
- properties: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes (files only).') },
341
- required: ['name', 'type'],
342
- },
343
- },
344
- },
345
- required: ['path', 'entries'],
346
- },
347
- write: false,
348
- handler: async (a, ctx: ToolContext) => {
349
- const dir = (a.path as string) || '';
350
- await recordOntologyRead(sessionOntologyGate, ctx, dir);
351
- const fs = await ctx.getFilesystem(a.branch as string);
352
- const entries = (await fs.readdir(dir || '.')) as DirEntry[];
353
- const filtered = await filterReadableEntries(readGateFor(a.branch as string, ctx), dir, entries);
354
- return { path: a.path ?? '', entries: filtered };
355
- },
356
- });
357
-
358
- mount({
359
- name: 'file_stat',
360
- description:
361
- 'Get a file/directory\'s metadata (name, type, size, …) without reading content.' +
362
- ONTOLOGY_BOUNDARY_NOTE,
363
- inputs: {
364
- type: 'object',
365
- properties: {
366
- branch: BRANCH_INPUT,
367
- path: str('Workspace-relative path.'),
368
- sessionId: SESSION_ID_INPUT,
369
- },
370
- required: ['branch', 'path'],
371
- additionalProperties: false,
372
- },
373
- outputs: {
374
- type: 'object',
375
- description: "The filesystem entry's metadata.",
376
- properties: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes.') },
377
- additionalProperties: true,
378
- },
379
- write: false,
380
- handler: async (a, ctx: ToolContext) => {
381
- const p = a.path as string;
382
- await recordOntologyRead(sessionOntologyGate, ctx, p);
383
- await assertCanRead(readGateFor(a.branch as string, ctx), p);
384
- return (await ctx.getFilesystem(a.branch as string)).stat(p);
385
- },
386
- });
387
-
388
- mount({
389
- name: 'grep',
390
- description:
391
- 'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced.' +
392
- ONTOLOGY_BOUNDARY_NOTE,
393
- inputs: {
394
- type: 'object',
395
- properties: {
396
- branch: BRANCH_INPUT,
397
- pattern: str('JavaScript regular expression.'),
398
- path: str('Subtree to search (default: whole workspace).'),
399
- ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
400
- max_results: { type: 'integer', minimum: 1, maximum: 1000, description: 'Cap on matches (default 200).' },
401
- sessionId: SESSION_ID_INPUT,
402
- },
403
- required: ['branch', 'pattern'],
404
- additionalProperties: false,
405
- },
406
- outputs: {
407
- type: 'object',
408
- properties: {
409
- matches: {
410
- type: 'array',
411
- description: 'Matching lines (capped by `max_results`).',
412
- items: {
413
- type: 'object',
414
- properties: { path: str('Workspace-relative file path.'), line: int('1-based line number.'), text: str('The matching line (truncated to 300 chars).') },
415
- required: ['path', 'line', 'text'],
416
- },
417
- },
418
- truncated: { type: 'boolean', description: 'True if the match cap was hit and results may be incomplete.' },
419
- },
420
- required: ['matches', 'truncated'],
421
- },
422
- write: false,
423
- handler: async (a, ctx: ToolContext) => {
424
- let re: RegExp;
425
- try {
426
- re = new RegExp(a.pattern as string, a.ignore_case ? 'i' : '');
427
- } catch (err) {
428
- throw new ToolError(`Invalid regex: ${(err as Error).message}`, 400);
429
- }
430
- const searchRoot = typeof a.path === 'string' ? a.path : '';
431
- // The search root itself is checked here (fail-closed for an agent grep on
432
- // a named subtree with no sessionId); each file the walk actually opens is
433
- // recorded per-file below, so a root-level grep that reaches into multiple
434
- // ontologies still records each one (and can poison later writes).
435
- await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
436
- const out: { path: string; line: number; text: string }[] = [];
437
- const max = typeof a.max_results === 'number' ? Math.min(a.max_results, 1000) : 200;
438
- await grepWalk(
439
- await ctx.getFilesystem(a.branch as string),
440
- searchRoot,
441
- re,
442
- out,
443
- max,
444
- 0,
445
- readGateFor(a.branch as string, ctx),
446
- (p) => recordOntologyRead(sessionOntologyGate, ctx, p),
447
- );
448
- return { matches: out, truncated: out.length >= max };
449
- },
450
- });
451
-
452
- // ── writes (through the lock/commit pipeline) ───────────────────────────
453
- mount({
454
- name: 'write_file',
455
- description:
456
- 'Write (create or overwrite) a workspace file. The change is committed + pushed as you. Returns `{ path, bytes }`.' +
457
- ONTOLOGY_BOUNDARY_NOTE,
458
- inputs: {
459
- type: 'object',
460
- properties: {
461
- branch: BRANCH_INPUT,
462
- path: str('Workspace-relative path.'),
463
- content: str('Full file content.'),
464
- sessionId: SESSION_ID_INPUT,
465
- },
466
- required: ['branch', 'path', 'content'],
467
- additionalProperties: false,
468
- },
469
- outputs: {
470
- type: 'object',
471
- properties: { path: str('The path written (echoes the input).'), bytes: int('Number of bytes written.') },
472
- required: ['path', 'bytes'],
473
- },
474
- write: true,
475
- handler: async (a, ctx: ToolContext) => {
476
- // NB: this is a no-op for chat + `ontology_ingest` — it only bites when a
477
- // routine executor has explicitly restricted THIS session's `ctx.sessionId`
478
- // (today only `watchlist_check`, to `.html`). Unrestricted sessions pass straight
479
- // through (see `assertPathWritable`), so it does not limit other agents.
480
- writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
481
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
482
- const fs = await ctx.getFilesystem(a.branch as string);
483
- await fs.writeFile(a.path as string, a.content as string);
484
- return { path: a.path, bytes: Buffer.byteLength(a.content as string, 'utf8') };
485
- },
486
- });
487
-
488
- mount({
489
- name: 'write_files',
490
- description:
491
- 'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
492
- 'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`; all ' +
493
- 'are created/overwritten and committed + pushed together as you. Prefer this over many write_file ' +
494
- 'calls. All files must be in the SAME ontology (the boundary below applies to the batch). Returns `{ count }`.' +
495
- ONTOLOGY_BOUNDARY_NOTE,
496
- inputs: {
497
- type: 'object',
498
- properties: {
499
- branch: BRANCH_INPUT,
500
- files: {
501
- type: 'array',
502
- description: 'Files to write; each created or overwritten.',
503
- items: {
504
- type: 'object',
505
- properties: { path: str('Workspace-relative path.'), content: str('Full file content.') },
506
- required: ['path', 'content'],
507
- additionalProperties: false,
508
- },
509
- },
510
- sessionId: SESSION_ID_INPUT,
511
- },
512
- required: ['branch', 'files'],
513
- additionalProperties: false,
514
- },
515
- outputs: {
516
- type: 'object',
517
- properties: { count: int('Number of files written.') },
518
- required: ['count'],
519
- },
520
- write: true,
521
- handler: async (a, ctx: ToolContext) => {
522
- const files = (a.files as Array<{ path: string; content: string }>) ?? [];
523
- if (files.length === 0) return { count: 0 };
524
- // Gate every path first (records ontology touches; a cross-ontology batch
525
- // is blocked exactly like the per-file write tools).
526
- for (const f of files) writePolicy.assertPathWritable(ctx.sessionId, f.path);
527
- for (const f of files) await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
528
- // `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
529
- // whole batch as one commit. Structural cast avoids a workflow-internal import.
530
- const fs = (await ctx.getFilesystem(a.branch as string)) as unknown as {
531
- writeFiles(writes: { path: string; content: string }[], summary: string): Promise<void>;
532
- };
533
- await fs.writeFiles(
534
- files.map((f) => ({ path: f.path, content: f.content })),
535
- `Write ${files.length} file(s)`,
536
- );
537
- return { count: files.length };
538
- },
539
- });
540
-
541
- mount({
542
- name: 'edit_file',
543
- description:
544
- 'Replace an exact string in a workspace file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
545
- ONTOLOGY_BOUNDARY_NOTE,
546
- inputs: {
547
- type: 'object',
548
- properties: {
549
- branch: BRANCH_INPUT,
550
- path: str('Workspace-relative path.'),
551
- old_string: str('Exact text to replace (include enough context to be unique).'),
552
- new_string: str('Replacement text.'),
553
- replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match.' },
554
- sessionId: SESSION_ID_INPUT,
555
- },
556
- required: ['branch', 'path', 'old_string', 'new_string'],
557
- additionalProperties: false,
558
- },
559
- outputs: {
560
- type: 'object',
561
- properties: { path: str('The path edited (echoes the input).'), replaced: int('Number of occurrences replaced.') },
562
- required: ['path', 'replaced'],
563
- },
564
- write: true,
565
- handler: async (a, ctx: ToolContext) => {
566
- writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
567
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
568
- const fs = await ctx.getFilesystem(a.branch as string);
569
- const path = a.path as string;
570
- const oldStr = a.old_string as string;
571
- const newStr = a.new_string as string;
572
- const content = asText(await fs.readFile(path));
573
- const count = oldStr ? content.split(oldStr).length - 1 : 0;
574
- if (count === 0) throw new ToolError('old_string not found in the file.', 400);
575
- if (count > 1 && a.replace_all !== true) {
576
- throw new ToolError(`old_string appears ${count} times — add more context to make it unique, or set replace_all.`, 400);
577
- }
578
- const updated = a.replace_all === true ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
579
- await fs.writeFile(path, updated);
580
- return { path, replaced: a.replace_all === true ? count : 1 };
581
- },
582
- });
583
-
584
- mount({
585
- name: 'delete_file',
586
- description: 'Delete a workspace file. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
587
- inputs: {
588
- type: 'object',
589
- properties: {
590
- branch: BRANCH_INPUT,
591
- path: str('Workspace-relative path.'),
592
- sessionId: SESSION_ID_INPUT,
593
- },
594
- required: ['branch', 'path'],
595
- additionalProperties: false,
596
- },
597
- outputs: {
598
- type: 'object',
599
- properties: { path: str('The path deleted (echoes the input).'), deleted: { type: 'boolean', description: 'Always true on success.' } },
600
- required: ['path', 'deleted'],
601
- },
602
- write: true,
603
- handler: async (a, ctx: ToolContext) => {
604
- // A delete propagates no cross-ontology information (it removes a node, it
605
- // doesn't carry bytes from elsewhere), so it is NOT ontology-write-gated — it
606
- // only records the ontology it touched, like a read. The extension policy
607
- // DOES apply though: a dashboard-only run must not delete graph `.md` nodes.
608
- writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
609
- await recordOntologyRead(sessionOntologyGate, ctx, a.path as string);
610
- await (await ctx.getFilesystem(a.branch as string)).deleteFile(a.path as string);
611
- return { path: a.path, deleted: true };
612
- },
613
- });
614
-
615
- mount({
616
- name: 'mkdir',
617
- description: 'Create a directory (recursive). An empty dir gets a `.gitkeep` so it persists in git.' + ONTOLOGY_BOUNDARY_NOTE,
618
- inputs: {
619
- type: 'object',
620
- properties: {
621
- branch: BRANCH_INPUT,
622
- path: str('Workspace-relative directory.'),
623
- sessionId: SESSION_ID_INPUT,
624
- },
625
- required: ['branch', 'path'],
626
- additionalProperties: false,
627
- },
628
- outputs: {
629
- type: 'object',
630
- properties: { path: str('The directory created (echoes the input).'), created: { type: 'boolean', description: 'Always true on success.' } },
631
- required: ['path', 'created'],
632
- },
633
- write: true,
634
- handler: async (a, ctx: ToolContext) => {
635
- writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
636
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
637
- await (await ctx.getFilesystem(a.branch as string)).mkdir(a.path as string, { recursive: true });
638
- return { path: a.path, created: true };
639
- },
640
- });
641
-
642
- mount({
643
- name: 'move_file',
644
- description: 'Move/rename a workspace file. Lands as a delete + create. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
645
- inputs: {
646
- type: 'object',
647
- properties: {
648
- branch: BRANCH_INPUT,
649
- src: str('Source path.'),
650
- dest: str('Destination path.'),
651
- sessionId: SESSION_ID_INPUT,
652
- },
653
- required: ['branch', 'src', 'dest'],
654
- additionalProperties: false,
655
- },
656
- outputs: {
657
- type: 'object',
658
- properties: { src: str('Source path (echoes the input).'), dest: str('Destination path (echoes the input).'), moved: { type: 'boolean', description: 'Always true on success.' } },
659
- required: ['src', 'dest', 'moved'],
660
- },
661
- write: true,
662
- handler: async (a, ctx: ToolContext) => {
663
- // A move CARRIES the source content into the destination — a genuine
664
- // cross-ontology flow if the two differ — so BOTH endpoints are write-gated
665
- // (unlike a plain delete, which moves no content). Check both BEFORE
666
- // touching disk so a blocked endpoint can't leave the source already deleted.
667
- writePolicy.assertPathWritable(ctx.sessionId, a.src as string);
668
- writePolicy.assertPathWritable(ctx.sessionId, a.dest as string);
669
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src as string);
670
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest as string);
671
- await (await ctx.getFilesystem(a.branch as string)).moveFile(a.src as string, a.dest as string);
672
- return { src: a.src, dest: a.dest, moved: true };
673
- },
674
- });
675
-
676
- mount({
677
- name: 'copy_file',
678
- description: 'Copy a workspace file to a new path. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
679
- inputs: {
680
- type: 'object',
681
- properties: {
682
- branch: BRANCH_INPUT,
683
- src: str('Source path.'),
684
- dest: str('Destination path.'),
685
- sessionId: SESSION_ID_INPUT,
686
- },
687
- required: ['branch', 'src', 'dest'],
688
- additionalProperties: false,
689
- },
690
- outputs: {
691
- type: 'object',
692
- properties: { src: str('Source path (echoes the input).'), dest: str('Destination path (echoes the input).'), copied: { type: 'boolean', description: 'Always true on success.' } },
693
- required: ['src', 'dest', 'copied'],
694
- },
695
- write: true,
696
- handler: async (a, ctx: ToolContext) => {
697
- // A copy CARRIES the source content into the destination — a genuine
698
- // cross-ontology flow if the two differ — so BOTH endpoints are write-gated.
699
- // Check both before touching disk.
700
- writePolicy.assertPathWritable(ctx.sessionId, a.src as string);
701
- writePolicy.assertPathWritable(ctx.sessionId, a.dest as string);
702
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src as string);
703
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest as string);
704
- await (await ctx.getFilesystem(a.branch as string)).copyFile(a.src as string, a.dest as string);
705
- return { src: a.src, dest: a.dest, copied: true };
706
- },
707
- });
708
-
709
- mount({
710
- name: 'unzip',
711
- description:
712
- 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.' +
713
- ONTOLOGY_BOUNDARY_NOTE,
714
- inputs: {
715
- type: 'object',
716
- properties: {
717
- branch: BRANCH_INPUT,
718
- path: str('Workspace-relative .zip path.'),
719
- destination: str("Directory to extract into (default: the zip's parent)."),
720
- sessionId: SESSION_ID_INPUT,
721
- },
722
- required: ['branch', 'path'],
723
- additionalProperties: false,
724
- },
725
- outputs: {
726
- type: 'object',
727
- properties: {
728
- destination: str('Directory the archive was extracted into.'),
729
- extracted: { type: 'array', items: { type: 'string' }, description: 'Workspace-relative paths of the files written.' },
730
- skipped: {
731
- type: 'array',
732
- description: 'Entries that were not extracted, with the reason.',
733
- items: {
734
- type: 'object',
735
- properties: { path: str('Entry path inside the archive.'), reason: str('Why it was skipped (e.g. unsafe path, size cap).') },
736
- required: ['path', 'reason'],
737
- },
738
- },
739
- },
740
- required: ['destination', 'extracted', 'skipped'],
741
- },
742
- write: true,
743
- handler: async (a, ctx: ToolContext) => {
744
- const zipPath = a.path as string;
745
- // Reading the source archive pins/records the source ontology, so a session
746
- // can't unzip from ontology A into ontology B without the A read counting.
747
- await recordOntologyRead(sessionOntologyGate, ctx, zipPath);
748
- return ctx.workspaceService.unzipFile(
749
- workspaceIdForBranch(a.branch as string),
750
- zipPath,
751
- typeof a.destination === 'string' ? a.destination : undefined,
752
- // Each extracted file is a write: a cross-ontology or write-blocked entry
753
- // is skipped (not extracted), so an archive can't bypass the boundary — the
754
- // extension policy applies per entry too, so a restricted run can't unzip a
755
- // `.md` into the graph.
756
- (wsRelPath) => {
757
- writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
758
- return assertOntologyWriteAllowed(sessionOntologyGate, ctx, wsRelPath);
759
- },
760
- );
761
- },
762
- });
763
-
764
- // ── shell (internal-only) ───────────────────────────────────────────────
765
- mount({
766
- name: 'execute_command',
767
- description:
768
- 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.' +
769
- ONTOLOGY_BOUNDARY_NOTE,
770
- internalOnly: true,
771
- inputs: {
772
- type: 'object',
773
- properties: {
774
- branch: BRANCH_INPUT,
775
- command: str('The shell command line to run.'),
776
- timeout_ms: { type: 'integer', minimum: 1000, maximum: 120000, description: 'Timeout in ms (default 30000).' },
777
- sessionId: SESSION_ID_INPUT,
778
- },
779
- // `branch` stays REQUIRED here on purpose, and must not be relaxed to make
780
- // the handler's focused-branch fallback "reachable". This list is the
781
- // contract the model is TAUGHT — declaring it required is what makes the
782
- // caller always name its branch, which is the fix for the workspace this
783
- // tool used to resolve as "undefined". It is not a runtime gate: nothing in
784
- // the tool route validates inputs against this schema, so a branch-less call
785
- // still reaches the handler and still hits the `ctx.focusedBranch` fallback
786
- // (covered by "falls back to the session focused branch when branch is
787
- // omitted"). The fallback is a safety net for a model that disobeys the
788
- // contract — not a sanctioned calling convention to advertise.
789
- required: ['branch', 'command'],
790
- additionalProperties: false,
791
- },
792
- outputs: {
793
- type: 'object',
794
- properties: {
795
- stdout: str('Captured standard output (truncated to 50,000 chars).'),
796
- stderr: str('Captured standard error (truncated to 50,000 chars).'),
797
- exitCode: int('Process exit code; -1 if it was killed (timeout/abort) or failed to spawn.'),
798
- },
799
- required: ['stdout', 'stderr', 'exitCode'],
800
- },
801
- write: true,
802
- handler: async (a, ctx: ToolContext) => {
803
- // Resolve the branch FIRST — before the policy gates below — so a malformed
804
- // call always gets the 400 that names what is missing, rather than a 403
805
- // from a restricted session masking it. Unlike the file tools (which route
806
- // through `getOrCreateForUser` and fall back to the default branch),
807
- // execute_command turns `branch` straight into a workspace — so a bad value
808
- // would `encodeURIComponent(undefined)` to the string "undefined", then try
809
- // to CLONE a branch literally named "undefined", 500ing and leaving an
810
- // `<workspaces>/undefined/` shell behind.
811
- //
812
- // Two distinct cases:
813
- // - branch OMITTED (`undefined`): the in-app chat agent may leave it off a
814
- // call. For an internal session we fall back to the caller's own focused
815
- // branch (`ctx.focusedBranch`, from its signed token), so the command runs
816
- // against the workspace the session is on (e.g. `main`) end to end. An
817
- // external caller carries no focused branch, so it stays absent → 400.
818
- // - branch PRESENT but invalid (the literal "undefined"/"null", empty, or
819
- // malformed): a broken value, never a real branch — fail closed, never
820
- // silently reinterpret it as the focused branch.
821
- const raw = a.branch;
822
- const branch = raw === undefined ? ctx.focusedBranch : raw;
823
- if (typeof branch !== 'string' || branch.length === 0) {
824
- throw new ToolError(
825
- 'execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.',
826
- 400,
827
- );
828
- }
829
- // A stringified absent value. Both are syntactically valid git branch names,
830
- // so `assertValidBranchName` below happily accepts them — and accepting one
831
- // is the whole bug this tool is guarded for: `getOrCreateForBranch("undefined")`
832
- // clones a branch literally named "undefined" into `<workspaces>/undefined/`
833
- // and 500s. Reject them by name, ahead of the shape check.
834
- if (branch === 'undefined' || branch === 'null') {
835
- throw new ToolError(
836
- `execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
837
- 'Pass the real branch (draft) whose workspace to run the command in.',
838
- 400,
839
- );
840
- }
841
- // Then the SHAPE, via the one canonical validator every other branch path
842
- // uses — no hand-maintained list of suspicious literals, which would both
843
- // miss malformed refs (`..`, `-x`, `foo/.lock`) and reserve names git
844
- // considers perfectly valid. Runs on the RAW string, so a whitespace-padded
845
- // `" main "` is refused rather than silently trimmed into a different
846
- // branch than the caller passed.
847
- try {
848
- assertValidBranchName(branch);
849
- } catch (err) {
850
- throw new ToolError(
851
- `execute_command got an invalid \`branch\`: ${(err as Error).message} — ` +
852
- 'pass the exact branch (draft) whose workspace to run the command in.',
853
- 400,
854
- );
855
- }
856
- // Shell is a write path with no single target path to check, so enforce the
857
- // boundary at the session level: refuse once the run is already write-blocked,
858
- // or when the run is restricted to a file type (shell could write anything).
859
- writePolicy.assertUnrestricted(ctx.sessionId);
860
- await assertShellAllowedWithinOntology(sessionOntologyGate, ctx);
861
- // Canonical per-branch bootstrap entry point — it owns the workspace-id
862
- // encoding and the single-flight clone, so the shell never derives a
863
- // workspace path by hand.
864
- const cwd = (await ctx.workspaceService.getOrCreateForBranch(branch)).absolutePath;
865
- const timeoutMs = typeof a.timeout_ms === 'number' ? a.timeout_ms : 30_000;
866
- // cwd is the workspace ROOT so shell paths match the workspace-relative
867
- // paths every other file tool uses — but the git clone lives one level
868
- // deeper, at <workspace>/<kbDirName>/.git. For a bare `git status` /
869
- // `git diff` (what the agent prompt teaches) point git there explicitly so
870
- // it resolves the KB clone instead of failing with "not a git repository".
871
- //
872
- // Because the command runs under `shell: true`, GIT_DIR/GIT_WORK_TREE
873
- // apply to the WHOLE shell, so restrict the override to a *standalone* bare
874
- // `git …` invocation. Otherwise the KB repo context would (a) leak into a
875
- // chained step that spawns its own git — `git commit && npm ci` — and (b)
876
- // override a caller's explicit `git -C …` / `--git-dir` / `--work-tree`.
877
- const command = (a.command as string).trim();
878
- const isStandaloneGit =
879
- /^git(\s|$)/.test(command) &&
880
- !/[;&|`\n]|\$\(/.test(command) &&
881
- !/(?:^|\s)(?:-C|--git-dir|--work-tree)(?:[=\s]|$)/.test(command);
882
- const env = isStandaloneGit
883
- ? {
884
- ...process.env,
885
- GIT_DIR: join(cwd, kbDirName, '.git'),
886
- GIT_WORK_TREE: join(cwd, kbDirName),
887
- }
888
- : process.env;
889
- return new Promise((resolve) => {
890
- const child = spawn(a.command as string, { cwd, shell: true, env });
891
- let stdout = '';
892
- let stderr = '';
893
- const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
894
- // If the caller disconnects (request aborted), kill the child instead of
895
- // letting it run to the timeout; the resulting `close`/`error` settles
896
- // the promise through `finish`.
897
- const onAbort = () => child.kill('SIGKILL');
898
- ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
899
- const finish = (value: unknown) => {
900
- clearTimeout(timer);
901
- ctx.abortSignal.removeEventListener('abort', onAbort);
902
- resolve(value);
903
- };
904
- // Cap accumulation while streaming so a verbose command can't exhaust
905
- // memory before `close`; the final slice keeps the output bound.
906
- const OUTPUT_CAP = 50_000;
907
- child.stdout.on('data', (d) => {
908
- if (stdout.length < OUTPUT_CAP) stdout += d.toString();
909
- });
910
- child.stderr.on('data', (d) => {
911
- if (stderr.length < OUTPUT_CAP) stderr += d.toString();
912
- });
913
- child.on('close', (code) => {
914
- finish({ stdout: stdout.slice(0, 50_000), stderr: stderr.slice(0, 50_000), exitCode: code ?? -1 });
915
- });
916
- child.on('error', (err) => {
917
- finish({ stdout, stderr: `${stderr}\n${String(err)}`.slice(0, 50_000), exitCode: -1 });
918
- });
919
- });
920
- },
921
- });
922
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import type { Router, RequestHandler } from 'express';
4
+ import type { LocalFilesystem } from '@mastra/core/workspace';
5
+ import type { IToolRegistry, JsonSchema } from '../tool-registry/tool.contract.js';
6
+ import { ToolError, type ToolContext, type ToolHandler } from '../tool-helpers/tool.contract.js';
7
+ import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
8
+ import {
9
+ recordOntologyRead,
10
+ assertOntologyWriteAllowed,
11
+ assertShellAllowedWithinOntology,
12
+ ONTOLOGY_BOUNDARY_NOTE,
13
+ SESSION_ID_INPUT,
14
+ type SessionOntologyGate,
15
+ } from './session-ontology.gate.js';
16
+ import type { IRoutineWritePolicy } from './routine-write-policy.js';
17
+ import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
18
+ import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
19
+ import { workspaceIdForBranch } from './workspace.service.js';
20
+ // Leaf-level shared primitive (same exception `workspace.service.ts` already
21
+ // relies on) — not a workflow service, so this stays inside the module boundary.
22
+ import { assertValidBranchName } from '../workflow/git/branch-name.js';
23
+ import type { ISessionSink } from './session-sink.js';
24
+ import type { IAccessControl } from '../access/access-control.interface.js';
25
+ import { toKbRelative, resolveReadableMap } from '../access/kb-read-filter.js';
26
+ import type { SpillStore } from './spill-store.js';
27
+
28
+ /** A directory entry as returned by `LocalFilesystem.readdir`. */
29
+ interface DirEntry {
30
+ name: string;
31
+ type: 'file' | 'directory';
32
+ size?: number;
33
+ }
34
+
35
+ /**
36
+ * Per-call read-permission gate. Built inside each read handler from the
37
+ * caller's identity (`ctx.user.email`) and the branch they targeted, then
38
+ * threaded into the filesystem walk so `read_file` / `list_files` / `grep`
39
+ * never surface a KB node the user may not read.
40
+ */
41
+ interface ReadGate {
42
+ accessControl: IAccessControl;
43
+ kbDirName: string;
44
+ workspaceId: string;
45
+ userEmail: string;
46
+ }
47
+
48
+ /** Throw a 403 ToolError if the gate denies reading `wsPath` (a KB node). */
49
+ async function assertCanRead(gate: ReadGate, wsPath: string): Promise<void> {
50
+ const rel = toKbRelative(wsPath, gate.kbDirName);
51
+ if (rel === null) return; // not a KB node — not governed by read rules
52
+ const ok = await gate.accessControl.canRead(gate.workspaceId, gate.userEmail, rel);
53
+ if (!ok) throw new ToolError(`You don't have permission to read "${wsPath}".`, 403);
54
+ }
55
+
56
+ /**
57
+ * Drop the file entries the caller may not read from a directory listing.
58
+ * Directory entries and non-KB files are always kept (a directory itself has
59
+ * no `read:` verdict; its restricted children are filtered when read/listed) —
60
+ * this is the AGENT's inclusion policy and differs from the explorer tree,
61
+ * which hides restricted directories outright. Uses the FULL `canReadBatch`
62
+ * (per-node frontmatter honoured), via the shared `resolveReadableMap`. One
63
+ * batched ACL load per directory.
64
+ */
65
+ async function filterReadableEntries(
66
+ gate: ReadGate,
67
+ dir: string,
68
+ entries: DirEntry[],
69
+ ): Promise<DirEntry[]> {
70
+ const fileWsPaths: string[] = [];
71
+ for (const e of entries) {
72
+ if (e.type === 'directory') continue;
73
+ fileWsPaths.push(dir ? `${dir}/${e.name}` : e.name);
74
+ }
75
+ if (fileWsPaths.length === 0) return entries;
76
+ const verdict = await resolveReadableMap(
77
+ (wid, email, rels) => gate.accessControl.canReadBatch(wid, email, rels),
78
+ gate.workspaceId,
79
+ gate.userEmail,
80
+ gate.kbDirName,
81
+ fileWsPaths,
82
+ );
83
+ return entries.filter((e) => {
84
+ if (e.type === 'directory') return true;
85
+ const wsPath = dir ? `${dir}/${e.name}` : e.name;
86
+ return verdict.get(wsPath) === true;
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Appended (centrally, in `mount`) to EVERY workspace tool description. A KB
92
+ * author can drop an `AGENTS.md` at the workspace root to document conventions
93
+ * for that knowledge base; agents (ours and external) should consult it before
94
+ * touching files. It rides on every entrypoint — reads (grep/list_files/
95
+ * file_stat) included — because any of them can be a session's first touch.
96
+ *
97
+ * `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
98
+ * rename still carry one, and the seeder never deletes a file it did not
99
+ * expect. Naming both means an agent finds the conventions either way, instead
100
+ * of reading none because it looked for the newer name and stopped.
101
+ */
102
+ const KB_CONVENTIONS_NOTE =
103
+ ' Before your first read or change in a workspace, read `AGENTS.md` at the KB root — or `CLAUDE.md` on a knowledge base seeded before it was renamed — if either exists: it holds the author\'s conventions for this knowledge base, and you should follow them.';
104
+
105
+ const int = (description: string): JsonSchema => ({ type: 'integer', description });
106
+
107
+ const str = (description: string): JsonSchema => ({ type: 'string', description });
108
+
109
+ function asText(content: string | Buffer): string {
110
+ return typeof content === 'string' ? content : content.toString('utf8');
111
+ }
112
+
113
+ /** JS grep over the workspace tree (read methods only) — bounded by match + depth caps. */
114
+ async function grepWalk(
115
+ fs: LocalFilesystem,
116
+ dir: string,
117
+ re: RegExp,
118
+ out: { path: string; line: number; text: string }[],
119
+ max: number,
120
+ depth: number,
121
+ gate: ReadGate,
122
+ recordOntologyRead: (path: string) => Promise<void>,
123
+ ): Promise<void> {
124
+ if (out.length >= max || depth > 12) return;
125
+ let entries;
126
+ try {
127
+ entries = (await fs.readdir(dir || '.')) as DirEntry[];
128
+ } catch {
129
+ return;
130
+ }
131
+ // Filter unreadable KB nodes out of the walk so grep never opens — or leaks
132
+ // a line from — a file the caller may not read.
133
+ entries = await filterReadableEntries(gate, dir, entries);
134
+ for (const e of entries) {
135
+ if (out.length >= max) return;
136
+ if (e.name === '.git' || e.name === 'node_modules') continue;
137
+ const p = dir ? `${dir}/${e.name}` : e.name;
138
+ if (e.type === 'directory') {
139
+ await grepWalk(fs, p, re, out, max, depth + 1, gate, recordOntologyRead);
140
+ } else {
141
+ // Opening a file under a named ontology is a read of that ontology — even
142
+ // for a root-level grep that resolves to a neutral root. Record it so a
143
+ // cross-ontology grep poisons later writes (closes the read-leak).
144
+ await recordOntologyRead(p);
145
+ let content;
146
+ try {
147
+ content = asText(await fs.readFile(p));
148
+ } catch {
149
+ continue;
150
+ }
151
+ if (content.includes(String.fromCharCode(0))) continue; // skip binary (NUL)
152
+ const lines = content.split('\n');
153
+ for (let i = 0; i < lines.length && out.length < max; i++) {
154
+ re.lastIndex = 0;
155
+ if (re.test(lines[i])) out.push({ path: p, line: i + 1, text: lines[i].slice(0, 300) });
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Workspace domain tools: the file primitives (replacing Mastra's auto-injected
163
+ * Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
164
+ * methods Mastra's tools call (via `ctx.getFilesystem(a.branch as string)`), so behaviour is
165
+ * identical and write-side ops still flow through the lock/commit pipeline.
166
+ * `edit_file`, `grep`, and `execute_command` are tool-level (no filesystem
167
+ * method), so they're implemented here. File ops are `both`; `execute_command`
168
+ * is INTERNAL-only (arbitrary shell as the caller is too dangerous to expose).
169
+ */
170
+ export function registerWorkspaceTools(
171
+ registry: IToolRegistry,
172
+ router: Router,
173
+ toolAuth: RequestHandler,
174
+ toolHandler: ToolHandlerFactory,
175
+ spillStore: SpillStore,
176
+ accessControl: IAccessControl,
177
+ kbDirName: string,
178
+ sessionOntologyGate: SessionOntologyGate,
179
+ writePolicy: IRoutineWritePolicy,
180
+ sessionSink: ISessionSink,
181
+ ): void {
182
+ /** Build the per-call read gate from the tool's branch input + caller identity. */
183
+ const readGateFor = (branch: string, ctx: ToolContext): ReadGate => ({
184
+ accessControl,
185
+ kbDirName,
186
+ workspaceId: workspaceIdForBranch(branch),
187
+ userEmail: ctx.user.email,
188
+ });
189
+
190
+ const mount = (spec: {
191
+ name: string;
192
+ description: string;
193
+ inputs: JsonSchema;
194
+ outputs?: JsonSchema;
195
+ write: boolean;
196
+ internalOnly?: boolean;
197
+ handler: ToolHandler;
198
+ }): void => {
199
+ const path = `/api/agent/tools/${spec.name}`;
200
+ const def = toolDef({
201
+ name: spec.name,
202
+ // Every workspace entrypoint carries the AGENTS.md reminder, appended once
203
+ // here so no tool (especially the read-only ones a session hits first) can
204
+ // miss it.
205
+ description: spec.description + KB_CONVENTIONS_NOTE,
206
+ path,
207
+ inputs: spec.inputs,
208
+ outputs: spec.outputs,
209
+ tags: spec.write ? ['workspace', 'write'] : ['workspace'],
210
+ });
211
+ registry.registerInternalTool(def);
212
+ if (!spec.internalOnly) registry.registerExternalTool(def);
213
+ // Internal-only tools (e.g. `execute_command`) keep their route mounted —
214
+ // our agent calls it over the same loopback — but gate it to internal-source
215
+ // callers so an external connection key can't invoke it by name.
216
+ router.post(
217
+ path.slice('/api'.length),
218
+ toolAuth,
219
+ ...(spec.internalOnly ? [requireInternalSource] : []),
220
+ toolHandler(spec.handler, { write: spec.write }),
221
+ );
222
+ };
223
+
224
+ // ── session bootstrap (external agents) ─────────────────────────────────
225
+ // Every read/write tool below scopes the ontology-session boundary off a
226
+ // `sessionId`. The in-process agent carries its thread id, but an external
227
+ // agent has no ambient run id and so cannot satisfy the gate until it has
228
+ // one. This mints that id up front (called ONCE); the MCP proxy then threads
229
+ // it onto every later gated call via its sessionId-output continuity
230
+ // convention. EXTERNAL-ONLY (not registered internal): the in-process agent
231
+ // already supplies its session id and ignores any body value.
232
+ //
233
+ // WHAT the minted id is backed by is the `ISessionSink` port's business
234
+ // (session-sink.ts). In the enterprise app it is a REAL chat-thread id, so
235
+ // the SAME id works end to end: KB reads scope the ontology boundary under
236
+ // it, AND `ask` accepts it (its sessionId IS a chat thread, resolved via
237
+ // getThread) — that unification is what stops a caller reading from one
238
+ // ontology and then having `ask` write into another. In a core-only
239
+ // deployment (no chat/ask) the default sink mints a bare id, which is all
240
+ // the ontology gate needs.
241
+ const startSessionDef = toolDef({
242
+ name: 'start_session',
243
+ description:
244
+ 'Mint the KnowledgeBase session id this run needs to read or write the knowledge ontologies. Call this ONCE, before any other KnowledgeBase tool, and only once per run — every gated tool needs the `sessionId` it returns to enforce the one-ontology-per-conversation boundary, and minting a new id mid-run resets that boundary. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: reads and ask then share one ontology boundary. Pass the returned id explicitly as `sessionId` on every subsequent KnowledgeBase tool call (direct MCP calls and inside `call_tool_chain` alike). Returns `{ sessionId }`.',
245
+ path: '/api/agent/tools/start_session',
246
+ inputs: { type: 'object', properties: {}, additionalProperties: false },
247
+ outputs: {
248
+ type: 'object',
249
+ properties: { sessionId: str('The minted session id — pass it as `sessionId` on subsequent KnowledgeBase tool calls and to `ask`.') },
250
+ required: ['sessionId'],
251
+ },
252
+ tags: ['workspace'],
253
+ });
254
+ registry.registerExternalTool(startSessionDef);
255
+ // Mint the session id via the sink and return it (see comment above: one id
256
+ // spans start_session -> reads -> ask, closing the ontology-pollution gap).
257
+ router.post(
258
+ '/agent/tools/start_session',
259
+ toolAuth,
260
+ // External-only: an internal token already carries its run's sessionId, so
261
+ // minting a new thread mid-run would reset the ontology boundary. Note
262
+ // "external" includes the MCP proxy's `externalProxy` loopback tokens
263
+ // (OAuth/JWT MCP sessions) — the verifier resolves those to
264
+ // `source: 'external'`, and one such session may legitimately mint several
265
+ // per-chat sessionIds over its lifetime.
266
+ requireExternalSource,
267
+ toolHandler(async (_args, ctx) => {
268
+ const { sessionId } = await sessionSink.createSession(ctx.user.id, new Date());
269
+ return { sessionId };
270
+ }),
271
+ );
272
+
273
+ // ── reads ──────────────────────────────────────────────────────────────
274
+ mount({
275
+ name: 'read_file',
276
+ description:
277
+ 'Read a workspace file as text. Returns `{ path, content }`. Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. A spill ref is workspace-independent: `branch` is ignored for it.' +
278
+ ONTOLOGY_BOUNDARY_NOTE,
279
+ inputs: {
280
+ type: 'object',
281
+ properties: {
282
+ branch: BRANCH_INPUT,
283
+ path: str('Workspace-relative path, or a `__tool_chain_spill__/…` ref from a truncated `call_tool_chain`.'),
284
+ offset: int('Start character index (default 0).'),
285
+ limit: int('Max characters to return from `offset`.'),
286
+ sessionId: SESSION_ID_INPUT,
287
+ },
288
+ required: ['branch', 'path'],
289
+ additionalProperties: false,
290
+ },
291
+ outputs: {
292
+ type: 'object',
293
+ properties: { path: str('The path that was read (echoes the input).'), content: str('File (or spill) content, sliced if offset/limit were given.') },
294
+ required: ['path', 'content'],
295
+ },
296
+ write: false,
297
+ handler: async (a, ctx: ToolContext) => {
298
+ const p = a.path as string;
299
+ const offset = typeof a.offset === 'number' ? a.offset : undefined;
300
+ const limit = typeof a.limit === 'number' ? a.limit : undefined;
301
+ if (spillStore.isSpillRef(p)) {
302
+ return { path: p, content: await spillStore.read(p, offset, limit) };
303
+ }
304
+ await recordOntologyRead(sessionOntologyGate, ctx, p);
305
+ await assertCanRead(readGateFor(a.branch as string, ctx), p);
306
+ const fs = await ctx.getFilesystem(a.branch as string);
307
+ const content = asText(await fs.readFile(p));
308
+ const start = offset && offset > 0 ? offset : 0;
309
+ const sliced = offset !== undefined || limit !== undefined
310
+ ? content.slice(start, limit !== undefined ? start + limit : undefined)
311
+ : content;
312
+ return { path: p, content: sliced };
313
+ },
314
+ });
315
+
316
+ mount({
317
+ name: 'list_files',
318
+ description:
319
+ 'List a directory. Returns `{ path, entries: [{ name, type, size? }] }`. Omit `path` for the workspace root.' +
320
+ ONTOLOGY_BOUNDARY_NOTE,
321
+ inputs: {
322
+ type: 'object',
323
+ properties: {
324
+ branch: BRANCH_INPUT,
325
+ path: str('Workspace-relative directory (default: root).'),
326
+ sessionId: SESSION_ID_INPUT,
327
+ },
328
+ required: ['branch'],
329
+ additionalProperties: false,
330
+ },
331
+ outputs: {
332
+ type: 'object',
333
+ properties: {
334
+ path: str('The directory listed (empty string for the root).'),
335
+ entries: {
336
+ type: 'array',
337
+ description: 'Directory entries.',
338
+ items: {
339
+ type: 'object',
340
+ properties: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes (files only).') },
341
+ required: ['name', 'type'],
342
+ },
343
+ },
344
+ },
345
+ required: ['path', 'entries'],
346
+ },
347
+ write: false,
348
+ handler: async (a, ctx: ToolContext) => {
349
+ const dir = (a.path as string) || '';
350
+ await recordOntologyRead(sessionOntologyGate, ctx, dir);
351
+ const fs = await ctx.getFilesystem(a.branch as string);
352
+ const entries = (await fs.readdir(dir || '.')) as DirEntry[];
353
+ const filtered = await filterReadableEntries(readGateFor(a.branch as string, ctx), dir, entries);
354
+ return { path: a.path ?? '', entries: filtered };
355
+ },
356
+ });
357
+
358
+ mount({
359
+ name: 'file_stat',
360
+ description:
361
+ 'Get a file/directory\'s metadata (name, type, size, …) without reading content.' +
362
+ ONTOLOGY_BOUNDARY_NOTE,
363
+ inputs: {
364
+ type: 'object',
365
+ properties: {
366
+ branch: BRANCH_INPUT,
367
+ path: str('Workspace-relative path.'),
368
+ sessionId: SESSION_ID_INPUT,
369
+ },
370
+ required: ['branch', 'path'],
371
+ additionalProperties: false,
372
+ },
373
+ outputs: {
374
+ type: 'object',
375
+ description: "The filesystem entry's metadata.",
376
+ properties: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes.') },
377
+ additionalProperties: true,
378
+ },
379
+ write: false,
380
+ handler: async (a, ctx: ToolContext) => {
381
+ const p = a.path as string;
382
+ await recordOntologyRead(sessionOntologyGate, ctx, p);
383
+ await assertCanRead(readGateFor(a.branch as string, ctx), p);
384
+ return (await ctx.getFilesystem(a.branch as string)).stat(p);
385
+ },
386
+ });
387
+
388
+ mount({
389
+ name: 'grep',
390
+ description:
391
+ 'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced.' +
392
+ ONTOLOGY_BOUNDARY_NOTE,
393
+ inputs: {
394
+ type: 'object',
395
+ properties: {
396
+ branch: BRANCH_INPUT,
397
+ pattern: str('JavaScript regular expression.'),
398
+ path: str('Subtree to search (default: whole workspace).'),
399
+ ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
400
+ max_results: { type: 'integer', minimum: 1, maximum: 1000, description: 'Cap on matches (default 200).' },
401
+ sessionId: SESSION_ID_INPUT,
402
+ },
403
+ required: ['branch', 'pattern'],
404
+ additionalProperties: false,
405
+ },
406
+ outputs: {
407
+ type: 'object',
408
+ properties: {
409
+ matches: {
410
+ type: 'array',
411
+ description: 'Matching lines (capped by `max_results`).',
412
+ items: {
413
+ type: 'object',
414
+ properties: { path: str('Workspace-relative file path.'), line: int('1-based line number.'), text: str('The matching line (truncated to 300 chars).') },
415
+ required: ['path', 'line', 'text'],
416
+ },
417
+ },
418
+ truncated: { type: 'boolean', description: 'True if the match cap was hit and results may be incomplete.' },
419
+ },
420
+ required: ['matches', 'truncated'],
421
+ },
422
+ write: false,
423
+ handler: async (a, ctx: ToolContext) => {
424
+ let re: RegExp;
425
+ try {
426
+ re = new RegExp(a.pattern as string, a.ignore_case ? 'i' : '');
427
+ } catch (err) {
428
+ throw new ToolError(`Invalid regex: ${(err as Error).message}`, 400);
429
+ }
430
+ const searchRoot = typeof a.path === 'string' ? a.path : '';
431
+ // The search root itself is checked here (fail-closed for an agent grep on
432
+ // a named subtree with no sessionId); each file the walk actually opens is
433
+ // recorded per-file below, so a root-level grep that reaches into multiple
434
+ // ontologies still records each one (and can poison later writes).
435
+ await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
436
+ const out: { path: string; line: number; text: string }[] = [];
437
+ const max = typeof a.max_results === 'number' ? Math.min(a.max_results, 1000) : 200;
438
+ await grepWalk(
439
+ await ctx.getFilesystem(a.branch as string),
440
+ searchRoot,
441
+ re,
442
+ out,
443
+ max,
444
+ 0,
445
+ readGateFor(a.branch as string, ctx),
446
+ (p) => recordOntologyRead(sessionOntologyGate, ctx, p),
447
+ );
448
+ return { matches: out, truncated: out.length >= max };
449
+ },
450
+ });
451
+
452
+ // ── writes (through the lock/commit pipeline) ───────────────────────────
453
+ mount({
454
+ name: 'write_file',
455
+ description:
456
+ 'Write (create or overwrite) a workspace file. The change is committed + pushed as you. Returns `{ path, bytes }`.' +
457
+ ONTOLOGY_BOUNDARY_NOTE,
458
+ inputs: {
459
+ type: 'object',
460
+ properties: {
461
+ branch: BRANCH_INPUT,
462
+ path: str('Workspace-relative path.'),
463
+ content: str('Full file content.'),
464
+ sessionId: SESSION_ID_INPUT,
465
+ },
466
+ required: ['branch', 'path', 'content'],
467
+ additionalProperties: false,
468
+ },
469
+ outputs: {
470
+ type: 'object',
471
+ properties: { path: str('The path written (echoes the input).'), bytes: int('Number of bytes written.') },
472
+ required: ['path', 'bytes'],
473
+ },
474
+ write: true,
475
+ handler: async (a, ctx: ToolContext) => {
476
+ // NB: this is a no-op for chat + `ontology_ingest` — it only bites when a
477
+ // routine executor has explicitly restricted THIS session's `ctx.sessionId`
478
+ // (today only `watchlist_check`, to `.html`). Unrestricted sessions pass straight
479
+ // through (see `assertPathWritable`), so it does not limit other agents.
480
+ writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
481
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
482
+ const fs = await ctx.getFilesystem(a.branch as string);
483
+ await fs.writeFile(a.path as string, a.content as string);
484
+ return { path: a.path, bytes: Buffer.byteLength(a.content as string, 'utf8') };
485
+ },
486
+ });
487
+
488
+ mount({
489
+ name: 'write_files',
490
+ description:
491
+ 'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
492
+ 'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`; all ' +
493
+ 'are created/overwritten and committed + pushed together as you. Prefer this over many write_file ' +
494
+ 'calls. All files must be in the SAME ontology (the boundary below applies to the batch). Returns `{ count }`.' +
495
+ ONTOLOGY_BOUNDARY_NOTE,
496
+ inputs: {
497
+ type: 'object',
498
+ properties: {
499
+ branch: BRANCH_INPUT,
500
+ files: {
501
+ type: 'array',
502
+ description: 'Files to write; each created or overwritten.',
503
+ items: {
504
+ type: 'object',
505
+ properties: { path: str('Workspace-relative path.'), content: str('Full file content.') },
506
+ required: ['path', 'content'],
507
+ additionalProperties: false,
508
+ },
509
+ },
510
+ sessionId: SESSION_ID_INPUT,
511
+ },
512
+ required: ['branch', 'files'],
513
+ additionalProperties: false,
514
+ },
515
+ outputs: {
516
+ type: 'object',
517
+ properties: { count: int('Number of files written.') },
518
+ required: ['count'],
519
+ },
520
+ write: true,
521
+ handler: async (a, ctx: ToolContext) => {
522
+ const files = (a.files as Array<{ path: string; content: string }>) ?? [];
523
+ if (files.length === 0) return { count: 0 };
524
+ // Gate every path first (records ontology touches; a cross-ontology batch
525
+ // is blocked exactly like the per-file write tools).
526
+ for (const f of files) writePolicy.assertPathWritable(ctx.sessionId, f.path);
527
+ for (const f of files) await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
528
+ // `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
529
+ // whole batch as one commit. Structural cast avoids a workflow-internal import.
530
+ const fs = (await ctx.getFilesystem(a.branch as string)) as unknown as {
531
+ writeFiles(writes: { path: string; content: string }[], summary: string): Promise<void>;
532
+ };
533
+ await fs.writeFiles(
534
+ files.map((f) => ({ path: f.path, content: f.content })),
535
+ `Write ${files.length} file(s)`,
536
+ );
537
+ return { count: files.length };
538
+ },
539
+ });
540
+
541
+ mount({
542
+ name: 'edit_file',
543
+ description:
544
+ 'Replace an exact string in a workspace file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
545
+ ONTOLOGY_BOUNDARY_NOTE,
546
+ inputs: {
547
+ type: 'object',
548
+ properties: {
549
+ branch: BRANCH_INPUT,
550
+ path: str('Workspace-relative path.'),
551
+ old_string: str('Exact text to replace (include enough context to be unique).'),
552
+ new_string: str('Replacement text.'),
553
+ replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match.' },
554
+ sessionId: SESSION_ID_INPUT,
555
+ },
556
+ required: ['branch', 'path', 'old_string', 'new_string'],
557
+ additionalProperties: false,
558
+ },
559
+ outputs: {
560
+ type: 'object',
561
+ properties: { path: str('The path edited (echoes the input).'), replaced: int('Number of occurrences replaced.') },
562
+ required: ['path', 'replaced'],
563
+ },
564
+ write: true,
565
+ handler: async (a, ctx: ToolContext) => {
566
+ writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
567
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
568
+ const fs = await ctx.getFilesystem(a.branch as string);
569
+ const path = a.path as string;
570
+ const oldStr = a.old_string as string;
571
+ const newStr = a.new_string as string;
572
+ const content = asText(await fs.readFile(path));
573
+ const count = oldStr ? content.split(oldStr).length - 1 : 0;
574
+ if (count === 0) throw new ToolError('old_string not found in the file.', 400);
575
+ if (count > 1 && a.replace_all !== true) {
576
+ throw new ToolError(`old_string appears ${count} times — add more context to make it unique, or set replace_all.`, 400);
577
+ }
578
+ const updated = a.replace_all === true ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
579
+ await fs.writeFile(path, updated);
580
+ return { path, replaced: a.replace_all === true ? count : 1 };
581
+ },
582
+ });
583
+
584
+ mount({
585
+ name: 'delete_file',
586
+ description: 'Delete a workspace file. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
587
+ inputs: {
588
+ type: 'object',
589
+ properties: {
590
+ branch: BRANCH_INPUT,
591
+ path: str('Workspace-relative path.'),
592
+ sessionId: SESSION_ID_INPUT,
593
+ },
594
+ required: ['branch', 'path'],
595
+ additionalProperties: false,
596
+ },
597
+ outputs: {
598
+ type: 'object',
599
+ properties: { path: str('The path deleted (echoes the input).'), deleted: { type: 'boolean', description: 'Always true on success.' } },
600
+ required: ['path', 'deleted'],
601
+ },
602
+ write: true,
603
+ handler: async (a, ctx: ToolContext) => {
604
+ // A delete propagates no cross-ontology information (it removes a node, it
605
+ // doesn't carry bytes from elsewhere), so it is NOT ontology-write-gated — it
606
+ // only records the ontology it touched, like a read. The extension policy
607
+ // DOES apply though: a dashboard-only run must not delete graph `.md` nodes.
608
+ writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
609
+ await recordOntologyRead(sessionOntologyGate, ctx, a.path as string);
610
+ await (await ctx.getFilesystem(a.branch as string)).deleteFile(a.path as string);
611
+ return { path: a.path, deleted: true };
612
+ },
613
+ });
614
+
615
+ mount({
616
+ name: 'mkdir',
617
+ description: 'Create a directory (recursive). An empty dir gets a `.gitkeep` so it persists in git.' + ONTOLOGY_BOUNDARY_NOTE,
618
+ inputs: {
619
+ type: 'object',
620
+ properties: {
621
+ branch: BRANCH_INPUT,
622
+ path: str('Workspace-relative directory.'),
623
+ sessionId: SESSION_ID_INPUT,
624
+ },
625
+ required: ['branch', 'path'],
626
+ additionalProperties: false,
627
+ },
628
+ outputs: {
629
+ type: 'object',
630
+ properties: { path: str('The directory created (echoes the input).'), created: { type: 'boolean', description: 'Always true on success.' } },
631
+ required: ['path', 'created'],
632
+ },
633
+ write: true,
634
+ handler: async (a, ctx: ToolContext) => {
635
+ writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
636
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path as string);
637
+ await (await ctx.getFilesystem(a.branch as string)).mkdir(a.path as string, { recursive: true });
638
+ return { path: a.path, created: true };
639
+ },
640
+ });
641
+
642
+ mount({
643
+ name: 'move_file',
644
+ description: 'Move/rename a workspace file. Lands as a delete + create. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
645
+ inputs: {
646
+ type: 'object',
647
+ properties: {
648
+ branch: BRANCH_INPUT,
649
+ src: str('Source path.'),
650
+ dest: str('Destination path.'),
651
+ sessionId: SESSION_ID_INPUT,
652
+ },
653
+ required: ['branch', 'src', 'dest'],
654
+ additionalProperties: false,
655
+ },
656
+ outputs: {
657
+ type: 'object',
658
+ properties: { src: str('Source path (echoes the input).'), dest: str('Destination path (echoes the input).'), moved: { type: 'boolean', description: 'Always true on success.' } },
659
+ required: ['src', 'dest', 'moved'],
660
+ },
661
+ write: true,
662
+ handler: async (a, ctx: ToolContext) => {
663
+ // A move CARRIES the source content into the destination — a genuine
664
+ // cross-ontology flow if the two differ — so BOTH endpoints are write-gated
665
+ // (unlike a plain delete, which moves no content). Check both BEFORE
666
+ // touching disk so a blocked endpoint can't leave the source already deleted.
667
+ writePolicy.assertPathWritable(ctx.sessionId, a.src as string);
668
+ writePolicy.assertPathWritable(ctx.sessionId, a.dest as string);
669
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src as string);
670
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest as string);
671
+ await (await ctx.getFilesystem(a.branch as string)).moveFile(a.src as string, a.dest as string);
672
+ return { src: a.src, dest: a.dest, moved: true };
673
+ },
674
+ });
675
+
676
+ mount({
677
+ name: 'copy_file',
678
+ description: 'Copy a workspace file to a new path. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
679
+ inputs: {
680
+ type: 'object',
681
+ properties: {
682
+ branch: BRANCH_INPUT,
683
+ src: str('Source path.'),
684
+ dest: str('Destination path.'),
685
+ sessionId: SESSION_ID_INPUT,
686
+ },
687
+ required: ['branch', 'src', 'dest'],
688
+ additionalProperties: false,
689
+ },
690
+ outputs: {
691
+ type: 'object',
692
+ properties: { src: str('Source path (echoes the input).'), dest: str('Destination path (echoes the input).'), copied: { type: 'boolean', description: 'Always true on success.' } },
693
+ required: ['src', 'dest', 'copied'],
694
+ },
695
+ write: true,
696
+ handler: async (a, ctx: ToolContext) => {
697
+ // A copy CARRIES the source content into the destination — a genuine
698
+ // cross-ontology flow if the two differ — so BOTH endpoints are write-gated.
699
+ // Check both before touching disk.
700
+ writePolicy.assertPathWritable(ctx.sessionId, a.src as string);
701
+ writePolicy.assertPathWritable(ctx.sessionId, a.dest as string);
702
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src as string);
703
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest as string);
704
+ await (await ctx.getFilesystem(a.branch as string)).copyFile(a.src as string, a.dest as string);
705
+ return { src: a.src, dest: a.dest, copied: true };
706
+ },
707
+ });
708
+
709
+ mount({
710
+ name: 'unzip',
711
+ description:
712
+ 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.' +
713
+ ONTOLOGY_BOUNDARY_NOTE,
714
+ inputs: {
715
+ type: 'object',
716
+ properties: {
717
+ branch: BRANCH_INPUT,
718
+ path: str('Workspace-relative .zip path.'),
719
+ destination: str("Directory to extract into (default: the zip's parent)."),
720
+ sessionId: SESSION_ID_INPUT,
721
+ },
722
+ required: ['branch', 'path'],
723
+ additionalProperties: false,
724
+ },
725
+ outputs: {
726
+ type: 'object',
727
+ properties: {
728
+ destination: str('Directory the archive was extracted into.'),
729
+ extracted: { type: 'array', items: { type: 'string' }, description: 'Workspace-relative paths of the files written.' },
730
+ skipped: {
731
+ type: 'array',
732
+ description: 'Entries that were not extracted, with the reason.',
733
+ items: {
734
+ type: 'object',
735
+ properties: { path: str('Entry path inside the archive.'), reason: str('Why it was skipped (e.g. unsafe path, size cap).') },
736
+ required: ['path', 'reason'],
737
+ },
738
+ },
739
+ },
740
+ required: ['destination', 'extracted', 'skipped'],
741
+ },
742
+ write: true,
743
+ handler: async (a, ctx: ToolContext) => {
744
+ const zipPath = a.path as string;
745
+ // Reading the source archive pins/records the source ontology, so a session
746
+ // can't unzip from ontology A into ontology B without the A read counting.
747
+ await recordOntologyRead(sessionOntologyGate, ctx, zipPath);
748
+ return ctx.workspaceService.unzipFile(
749
+ workspaceIdForBranch(a.branch as string),
750
+ zipPath,
751
+ typeof a.destination === 'string' ? a.destination : undefined,
752
+ // Each extracted file is a write: a cross-ontology or write-blocked entry
753
+ // is skipped (not extracted), so an archive can't bypass the boundary — the
754
+ // extension policy applies per entry too, so a restricted run can't unzip a
755
+ // `.md` into the graph.
756
+ (wsRelPath) => {
757
+ writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
758
+ return assertOntologyWriteAllowed(sessionOntologyGate, ctx, wsRelPath);
759
+ },
760
+ );
761
+ },
762
+ });
763
+
764
+ // ── shell (internal-only) ───────────────────────────────────────────────
765
+ mount({
766
+ name: 'execute_command',
767
+ description:
768
+ 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.' +
769
+ ONTOLOGY_BOUNDARY_NOTE,
770
+ internalOnly: true,
771
+ inputs: {
772
+ type: 'object',
773
+ properties: {
774
+ branch: BRANCH_INPUT,
775
+ command: str('The shell command line to run.'),
776
+ timeout_ms: { type: 'integer', minimum: 1000, maximum: 120000, description: 'Timeout in ms (default 30000).' },
777
+ sessionId: SESSION_ID_INPUT,
778
+ },
779
+ // `branch` stays REQUIRED here on purpose, and must not be relaxed to make
780
+ // the handler's focused-branch fallback "reachable". This list is the
781
+ // contract the model is TAUGHT — declaring it required is what makes the
782
+ // caller always name its branch, which is the fix for the workspace this
783
+ // tool used to resolve as "undefined". It is not a runtime gate: nothing in
784
+ // the tool route validates inputs against this schema, so a branch-less call
785
+ // still reaches the handler and still hits the `ctx.focusedBranch` fallback
786
+ // (covered by "falls back to the session focused branch when branch is
787
+ // omitted"). The fallback is a safety net for a model that disobeys the
788
+ // contract — not a sanctioned calling convention to advertise.
789
+ required: ['branch', 'command'],
790
+ additionalProperties: false,
791
+ },
792
+ outputs: {
793
+ type: 'object',
794
+ properties: {
795
+ stdout: str('Captured standard output (truncated to 50,000 chars).'),
796
+ stderr: str('Captured standard error (truncated to 50,000 chars).'),
797
+ exitCode: int('Process exit code; -1 if it was killed (timeout/abort) or failed to spawn.'),
798
+ },
799
+ required: ['stdout', 'stderr', 'exitCode'],
800
+ },
801
+ write: true,
802
+ handler: async (a, ctx: ToolContext) => {
803
+ // Resolve the branch FIRST — before the policy gates below — so a malformed
804
+ // call always gets the 400 that names what is missing, rather than a 403
805
+ // from a restricted session masking it. Unlike the file tools (which route
806
+ // through `getOrCreateForUser` and fall back to the default branch),
807
+ // execute_command turns `branch` straight into a workspace — so a bad value
808
+ // would `encodeURIComponent(undefined)` to the string "undefined", then try
809
+ // to CLONE a branch literally named "undefined", 500ing and leaving an
810
+ // `<workspaces>/undefined/` shell behind.
811
+ //
812
+ // Two distinct cases:
813
+ // - branch OMITTED (`undefined`): the in-app chat agent may leave it off a
814
+ // call. For an internal session we fall back to the caller's own focused
815
+ // branch (`ctx.focusedBranch`, from its signed token), so the command runs
816
+ // against the workspace the session is on (e.g. `main`) end to end. An
817
+ // external caller carries no focused branch, so it stays absent → 400.
818
+ // - branch PRESENT but invalid (the literal "undefined"/"null", empty, or
819
+ // malformed): a broken value, never a real branch — fail closed, never
820
+ // silently reinterpret it as the focused branch.
821
+ const raw = a.branch;
822
+ const branch = raw === undefined ? ctx.focusedBranch : raw;
823
+ if (typeof branch !== 'string' || branch.length === 0) {
824
+ throw new ToolError(
825
+ 'execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.',
826
+ 400,
827
+ );
828
+ }
829
+ // A stringified absent value. Both are syntactically valid git branch names,
830
+ // so `assertValidBranchName` below happily accepts them — and accepting one
831
+ // is the whole bug this tool is guarded for: `getOrCreateForBranch("undefined")`
832
+ // clones a branch literally named "undefined" into `<workspaces>/undefined/`
833
+ // and 500s. Reject them by name, ahead of the shape check.
834
+ if (branch === 'undefined' || branch === 'null') {
835
+ throw new ToolError(
836
+ `execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
837
+ 'Pass the real branch (draft) whose workspace to run the command in.',
838
+ 400,
839
+ );
840
+ }
841
+ // Then the SHAPE, via the one canonical validator every other branch path
842
+ // uses — no hand-maintained list of suspicious literals, which would both
843
+ // miss malformed refs (`..`, `-x`, `foo/.lock`) and reserve names git
844
+ // considers perfectly valid. Runs on the RAW string, so a whitespace-padded
845
+ // `" main "` is refused rather than silently trimmed into a different
846
+ // branch than the caller passed.
847
+ try {
848
+ assertValidBranchName(branch);
849
+ } catch (err) {
850
+ throw new ToolError(
851
+ `execute_command got an invalid \`branch\`: ${(err as Error).message} — ` +
852
+ 'pass the exact branch (draft) whose workspace to run the command in.',
853
+ 400,
854
+ );
855
+ }
856
+ // Shell is a write path with no single target path to check, so enforce the
857
+ // boundary at the session level: refuse once the run is already write-blocked,
858
+ // or when the run is restricted to a file type (shell could write anything).
859
+ writePolicy.assertUnrestricted(ctx.sessionId);
860
+ await assertShellAllowedWithinOntology(sessionOntologyGate, ctx);
861
+ // Canonical per-branch bootstrap entry point — it owns the workspace-id
862
+ // encoding and the single-flight clone, so the shell never derives a
863
+ // workspace path by hand.
864
+ const cwd = (await ctx.workspaceService.getOrCreateForBranch(branch)).absolutePath;
865
+ const timeoutMs = typeof a.timeout_ms === 'number' ? a.timeout_ms : 30_000;
866
+ // cwd is the workspace ROOT so shell paths match the workspace-relative
867
+ // paths every other file tool uses — but the git clone lives one level
868
+ // deeper, at <workspace>/<kbDirName>/.git. For a bare `git status` /
869
+ // `git diff` (what the agent prompt teaches) point git there explicitly so
870
+ // it resolves the KB clone instead of failing with "not a git repository".
871
+ //
872
+ // Because the command runs under `shell: true`, GIT_DIR/GIT_WORK_TREE
873
+ // apply to the WHOLE shell, so restrict the override to a *standalone* bare
874
+ // `git …` invocation. Otherwise the KB repo context would (a) leak into a
875
+ // chained step that spawns its own git — `git commit && npm ci` — and (b)
876
+ // override a caller's explicit `git -C …` / `--git-dir` / `--work-tree`.
877
+ const command = (a.command as string).trim();
878
+ const isStandaloneGit =
879
+ /^git(\s|$)/.test(command) &&
880
+ !/[;&|`\n]|\$\(/.test(command) &&
881
+ !/(?:^|\s)(?:-C|--git-dir|--work-tree)(?:[=\s]|$)/.test(command);
882
+ const env = isStandaloneGit
883
+ ? {
884
+ ...process.env,
885
+ GIT_DIR: join(cwd, kbDirName, '.git'),
886
+ GIT_WORK_TREE: join(cwd, kbDirName),
887
+ }
888
+ : process.env;
889
+ return new Promise((resolve) => {
890
+ const child = spawn(a.command as string, { cwd, shell: true, env });
891
+ let stdout = '';
892
+ let stderr = '';
893
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
894
+ // If the caller disconnects (request aborted), kill the child instead of
895
+ // letting it run to the timeout; the resulting `close`/`error` settles
896
+ // the promise through `finish`.
897
+ const onAbort = () => child.kill('SIGKILL');
898
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
899
+ const finish = (value: unknown) => {
900
+ clearTimeout(timer);
901
+ ctx.abortSignal.removeEventListener('abort', onAbort);
902
+ resolve(value);
903
+ };
904
+ // Cap accumulation while streaming so a verbose command can't exhaust
905
+ // memory before `close`; the final slice keeps the output bound.
906
+ const OUTPUT_CAP = 50_000;
907
+ child.stdout.on('data', (d) => {
908
+ if (stdout.length < OUTPUT_CAP) stdout += d.toString();
909
+ });
910
+ child.stderr.on('data', (d) => {
911
+ if (stderr.length < OUTPUT_CAP) stderr += d.toString();
912
+ });
913
+ child.on('close', (code) => {
914
+ finish({ stdout: stdout.slice(0, 50_000), stderr: stderr.slice(0, 50_000), exitCode: code ?? -1 });
915
+ });
916
+ child.on('error', (err) => {
917
+ finish({ stdout, stderr: `${stderr}\n${String(err)}`.slice(0, 50_000), exitCode: -1 });
918
+ });
919
+ });
920
+ },
921
+ });
922
+ }