@bevel-software/platform-core-backend 0.19.0 → 0.20.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 (255) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +22 -0
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +10 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/audit/agent-audit.service.d.ts +54 -0
  9. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
  10. package/dist/modules/audit/agent-audit.service.js +290 -0
  11. package/dist/modules/audit/agent-audit.service.js.map +1 -0
  12. package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
  13. package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
  14. package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
  15. package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
  16. package/dist/modules/audit/audit.contract.d.ts +145 -0
  17. package/dist/modules/audit/audit.contract.d.ts.map +1 -0
  18. package/dist/modules/audit/audit.contract.js +35 -0
  19. package/dist/modules/audit/audit.contract.js.map +1 -0
  20. package/dist/modules/audit/audit.routes.d.ts +21 -0
  21. package/dist/modules/audit/audit.routes.d.ts.map +1 -0
  22. package/dist/modules/audit/audit.routes.js +157 -0
  23. package/dist/modules/audit/audit.routes.js.map +1 -0
  24. package/dist/modules/audit/event-classifier.d.ts +52 -0
  25. package/dist/modules/audit/event-classifier.d.ts.map +1 -0
  26. package/dist/modules/audit/event-classifier.js +123 -0
  27. package/dist/modules/audit/event-classifier.js.map +1 -0
  28. package/dist/modules/audit/index.d.ts +7 -0
  29. package/dist/modules/audit/index.d.ts.map +1 -0
  30. package/dist/modules/audit/index.js +7 -0
  31. package/dist/modules/audit/index.js.map +1 -0
  32. package/dist/modules/audit/request-audit.d.ts +57 -0
  33. package/dist/modules/audit/request-audit.d.ts.map +1 -0
  34. package/dist/modules/audit/request-audit.js +117 -0
  35. package/dist/modules/audit/request-audit.js.map +1 -0
  36. package/dist/modules/database/core-schema.d.ts +414 -0
  37. package/dist/modules/database/core-schema.d.ts.map +1 -1
  38. package/dist/modules/database/core-schema.js +99 -0
  39. package/dist/modules/database/core-schema.js.map +1 -1
  40. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  41. package/dist/modules/diff/diff.service.js +6 -1
  42. package/dist/modules/diff/diff.service.js.map +1 -1
  43. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +9 -1
  44. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -1
  45. package/dist/modules/kb-fs/git-guarded-filesystem.js +16 -3
  46. package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -1
  47. package/dist/modules/mcp/mcp-auth.middleware.d.ts +1 -0
  48. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  49. package/dist/modules/mcp/mcp-auth.middleware.js +32 -0
  50. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  51. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  52. package/dist/modules/mcp/mcp.routes.js +8 -1
  53. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  54. package/dist/modules/mcp/mcp.service.d.ts +9 -1
  55. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  56. package/dist/modules/mcp/mcp.service.js +48 -5
  57. package/dist/modules/mcp/mcp.service.js.map +1 -1
  58. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
  59. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  60. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +180 -8
  61. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  62. package/dist/modules/settings/deployment-settings.service.d.ts +10 -2
  63. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  64. package/dist/modules/settings/deployment-settings.service.js +32 -6
  65. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  66. package/dist/modules/tool-auth/external-api-key.interface.d.ts +21 -5
  67. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  68. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  69. package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
  70. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  71. package/dist/modules/tool-auth/external-api-key.service.js +22 -9
  72. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  73. package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
  74. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  75. package/dist/modules/tool-auth/internal-token.service.js +1 -0
  76. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  77. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
  78. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  79. package/dist/modules/tool-auth/tool-auth.middleware.js +9 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  81. package/dist/modules/workspace/workspace.service.d.ts +8 -0
  82. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  83. package/dist/modules/workspace/workspace.service.js +9 -0
  84. package/dist/modules/workspace/workspace.service.js.map +1 -1
  85. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  86. package/dist/modules/workspace/workspace.tools.js +49 -13
  87. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  88. package/dist/shared/git-internals.d.ts +7 -1
  89. package/dist/shared/git-internals.d.ts.map +1 -1
  90. package/dist/shared/git-internals.js +67 -9
  91. package/dist/shared/git-internals.js.map +1 -1
  92. package/migrations/0012_agent_audit.sql +41 -0
  93. package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
  94. package/migrations/meta/0012_snapshot.json +2247 -0
  95. package/migrations/meta/0013_snapshot.json +2259 -0
  96. package/migrations/meta/_journal.json +14 -0
  97. package/package.json +4 -4
  98. package/src/core/create-core-server.ts +28 -0
  99. package/src/core/create-core-services.ts +13 -0
  100. package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
  101. package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
  102. package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
  103. package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
  104. package/src/modules/audit/agent-audit.service.ts +316 -0
  105. package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
  106. package/src/modules/audit/audit.contract.ts +164 -0
  107. package/src/modules/audit/audit.routes.ts +168 -0
  108. package/src/modules/audit/event-classifier.ts +155 -0
  109. package/src/modules/audit/index.ts +14 -0
  110. package/src/modules/audit/request-audit.ts +141 -0
  111. package/src/modules/database/core-schema.ts +104 -0
  112. package/src/modules/diff/diff.service.ts +6 -1
  113. package/src/modules/kb-fs/git-guarded-filesystem.ts +16 -3
  114. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
  115. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -2
  116. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
  117. package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +1 -1
  118. package/src/modules/mcp/__tests__/mcp.service.test.ts +120 -3
  119. package/src/modules/mcp/mcp-auth.middleware.ts +31 -0
  120. package/src/modules/mcp/mcp.routes.ts +11 -1
  121. package/src/modules/mcp/mcp.service.ts +62 -4
  122. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +184 -6
  123. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +35 -0
  124. package/src/modules/settings/deployment-settings.service.ts +47 -8
  125. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +29 -8
  126. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
  127. package/src/modules/tool-auth/external-api-key.interface.ts +22 -5
  128. package/src/modules/tool-auth/external-api-key.service.ts +25 -8
  129. package/src/modules/tool-auth/internal-token.service.ts +11 -0
  130. package/src/modules/tool-auth/tool-auth.middleware.ts +17 -1
  131. package/src/modules/workspace/__tests__/git-internals.security.test.ts +135 -44
  132. package/src/modules/workspace/workspace.service.ts +9 -0
  133. package/src/modules/workspace/workspace.tools.ts +47 -11
  134. package/src/shared/__tests__/git-internals.test.ts +41 -0
  135. package/src/shared/git-internals.ts +61 -6
  136. package/dist/modules/access/access-errors.d.ts +0 -34
  137. package/dist/modules/access/access-errors.d.ts.map +0 -1
  138. package/dist/modules/access/access-errors.js +0 -40
  139. package/dist/modules/access/access-errors.js.map +0 -1
  140. package/dist/modules/access/access-splice.d.ts +0 -140
  141. package/dist/modules/access/access-splice.d.ts.map +0 -1
  142. package/dist/modules/access/access-splice.js +0 -389
  143. package/dist/modules/access/access-splice.js.map +0 -1
  144. package/dist/modules/access/group-files.d.ts +0 -83
  145. package/dist/modules/access/group-files.d.ts.map +0 -1
  146. package/dist/modules/access/group-files.js +0 -167
  147. package/dist/modules/access/group-files.js.map +0 -1
  148. package/dist/modules/access/kb-read-filter.d.ts +0 -41
  149. package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
  150. package/dist/modules/access/kb-read-filter.js +0 -60
  151. package/dist/modules/access/kb-read-filter.js.map +0 -1
  152. package/dist/modules/access/render-roles-yaml.d.ts +0 -22
  153. package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
  154. package/dist/modules/access/render-roles-yaml.js +0 -56
  155. package/dist/modules/access/render-roles-yaml.js.map +0 -1
  156. package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
  157. package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
  158. package/dist/modules/access/roles-yaml-guard.js +0 -79
  159. package/dist/modules/access/roles-yaml-guard.js.map +0 -1
  160. package/dist/modules/diff/diff-paths.d.ts +0 -2
  161. package/dist/modules/diff/diff-paths.d.ts.map +0 -1
  162. package/dist/modules/diff/diff-paths.js +0 -9
  163. package/dist/modules/diff/diff-paths.js.map +0 -1
  164. package/dist/modules/groups/group-provision.service.d.ts +0 -130
  165. package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
  166. package/dist/modules/groups/group-provision.service.js +0 -288
  167. package/dist/modules/groups/group-provision.service.js.map +0 -1
  168. package/dist/modules/groups/groups.contract.d.ts +0 -106
  169. package/dist/modules/groups/groups.contract.d.ts.map +0 -1
  170. package/dist/modules/groups/groups.contract.js +0 -36
  171. package/dist/modules/groups/groups.contract.js.map +0 -1
  172. package/dist/modules/groups/groups.routes.d.ts +0 -42
  173. package/dist/modules/groups/groups.routes.d.ts.map +0 -1
  174. package/dist/modules/groups/groups.routes.js +0 -379
  175. package/dist/modules/groups/groups.routes.js.map +0 -1
  176. package/dist/modules/groups/groups.service.d.ts +0 -60
  177. package/dist/modules/groups/groups.service.d.ts.map +0 -1
  178. package/dist/modules/groups/groups.service.js +0 -172
  179. package/dist/modules/groups/groups.service.js.map +0 -1
  180. package/dist/modules/groups/index.d.ts +0 -7
  181. package/dist/modules/groups/index.d.ts.map +0 -1
  182. package/dist/modules/groups/index.js +0 -6
  183. package/dist/modules/groups/index.js.map +0 -1
  184. package/dist/modules/groups/join-proposals.d.ts +0 -53
  185. package/dist/modules/groups/join-proposals.d.ts.map +0 -1
  186. package/dist/modules/groups/join-proposals.js +0 -67
  187. package/dist/modules/groups/join-proposals.js.map +0 -1
  188. package/dist/modules/groups/join-requests.service.d.ts +0 -81
  189. package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
  190. package/dist/modules/groups/join-requests.service.js +0 -135
  191. package/dist/modules/groups/join-requests.service.js.map +0 -1
  192. package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
  193. package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
  194. package/dist/modules/mcp/mcp-session-store.js +0 -131
  195. package/dist/modules/mcp/mcp-session-store.js.map +0 -1
  196. package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
  197. package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
  198. package/dist/modules/workflow/file-change-notifier.js +0 -22
  199. package/dist/modules/workflow/file-change-notifier.js.map +0 -1
  200. package/dist/modules/workflow/git/branch-name.d.ts +0 -10
  201. package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
  202. package/dist/modules/workflow/git/branch-name.js +0 -76
  203. package/dist/modules/workflow/git/branch-name.js.map +0 -1
  204. package/dist/modules/workflow/git/clone-config.d.ts +0 -61
  205. package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
  206. package/dist/modules/workflow/git/clone-config.js +0 -69
  207. package/dist/modules/workflow/git/clone-config.js.map +0 -1
  208. package/dist/modules/workflow/git/mutex.d.ts +0 -11
  209. package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
  210. package/dist/modules/workflow/git/mutex.js +0 -23
  211. package/dist/modules/workflow/git/mutex.js.map +0 -1
  212. package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
  213. package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
  214. package/dist/modules/workflow/locking-filesystem.js +0 -553
  215. package/dist/modules/workflow/locking-filesystem.js.map +0 -1
  216. package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
  217. package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
  218. package/dist/modules/workflow/read-only-filesystem.js +0 -39
  219. package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
  220. package/dist/modules/workflow/workflow.errors.d.ts +0 -197
  221. package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
  222. package/dist/modules/workflow/workflow.errors.js +0 -298
  223. package/dist/modules/workflow/workflow.errors.js.map +0 -1
  224. package/dist/modules/workspace/bevel-ignore.d.ts +0 -30
  225. package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
  226. package/dist/modules/workspace/bevel-ignore.js +0 -61
  227. package/dist/modules/workspace/bevel-ignore.js.map +0 -1
  228. package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
  229. package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
  230. package/dist/modules/workspace/kb-seed.interface.js +0 -2
  231. package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
  232. package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
  233. package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
  234. package/dist/modules/workspace/kb-seed.service.js +0 -535
  235. package/dist/modules/workspace/kb-seed.service.js.map +0 -1
  236. package/dist/modules/workspace/plugins-migration.d.ts +0 -50
  237. package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
  238. package/dist/modules/workspace/plugins-migration.js +0 -379
  239. package/dist/modules/workspace/plugins-migration.js.map +0 -1
  240. package/dist/shared/fs-errors.d.ts +0 -8
  241. package/dist/shared/fs-errors.d.ts.map +0 -1
  242. package/dist/shared/fs-errors.js +0 -11
  243. package/dist/shared/fs-errors.js.map +0 -1
  244. package/dist/shared/fs-walk.d.ts +0 -23
  245. package/dist/shared/fs-walk.d.ts.map +0 -1
  246. package/dist/shared/fs-walk.js +0 -42
  247. package/dist/shared/fs-walk.js.map +0 -1
  248. package/dist/shared/hash-email.d.ts +0 -11
  249. package/dist/shared/hash-email.d.ts.map +0 -1
  250. package/dist/shared/hash-email.js +0 -14
  251. package/dist/shared/hash-email.js.map +0 -1
  252. package/dist/shared/kb-walk.d.ts +0 -52
  253. package/dist/shared/kb-walk.d.ts.map +0 -1
  254. package/dist/shared/kb-walk.js +0 -76
  255. package/dist/shared/kb-walk.js.map +0 -1
@@ -144,14 +144,25 @@ export class ExternalApiKeyService implements IExternalApiKeyService {
144
144
  }
145
145
 
146
146
  async listForUser(userId: string): Promise<ExternalApiKeySummary[]> {
147
+ // A key the owner deleted for good is gone from THEIR listings — that is
148
+ // what the deletion means to them; the row lives on for the Audit log.
147
149
  const rows = await this.db
148
150
  .select()
149
151
  .from(externalApiKeys)
150
- .where(eq(externalApiKeys.userId, userId))
152
+ .where(and(eq(externalApiKeys.userId, userId), isNull(externalApiKeys.deletedAt)))
151
153
  .orderBy(desc(externalApiKeys.createdAt));
152
154
  return rows.map(toSummary);
153
155
  }
154
156
 
157
+ async ownerOf(id: string): Promise<string | null> {
158
+ const [row] = await this.db
159
+ .select({ userId: externalApiKeys.userId })
160
+ .from(externalApiKeys)
161
+ .where(eq(externalApiKeys.id, id))
162
+ .limit(1);
163
+ return row?.userId ?? null;
164
+ }
165
+
155
166
  async listForDeployment(): Promise<AdminExternalApiKeySummary[]> {
156
167
  // Owner joined in so the admin overview is one round-trip; ordered by
157
168
  // owner email so per-account grouping is a linear pass, newest key
@@ -219,14 +230,16 @@ export class ExternalApiKeyService implements IExternalApiKeyService {
219
230
  }
220
231
 
221
232
  async remove(id: string, userId: string): Promise<void> {
222
- // Only revoked rows may be hard-deleted — deleting an active key would
233
+ // Only revoked rows may be deleted — deleting an active key would
223
234
  // silently cut off a live agent. Scope by userId so a user can only
224
235
  // delete their own tokens. Validate first (so we can return a precise
225
- // not-found vs still-active error), then delete inside a transaction.
236
+ // not-found vs still-active error), then mark the row deleted.
226
237
  const [existing] = await this.db
227
238
  .select({ revokedAt: externalApiKeys.revokedAt })
228
239
  .from(externalApiKeys)
229
- .where(and(eq(externalApiKeys.id, id), eq(externalApiKeys.userId, userId)))
240
+ .where(
241
+ and(eq(externalApiKeys.id, id), eq(externalApiKeys.userId, userId), isNull(externalApiKeys.deletedAt)),
242
+ )
230
243
  .limit(1);
231
244
  if (!existing) {
232
245
  throw new TokenNotFoundError();
@@ -236,16 +249,19 @@ export class ExternalApiKeyService implements IExternalApiKeyService {
236
249
  throw new TokenStillActiveError();
237
250
  }
238
251
 
239
- // Dependents (e.g. the enterprise LLM-usage metering rows) hang off this
240
- // row via ON DELETE CASCADE foreign keys, so a bare delete takes any audit
241
- // trail with it — this service doesn't have to know those tables exist.
252
+ // A deletion in name: the row stays, marked, because the Audit log's
253
+ // events hang off it and the trail must outlive the key's owner's wish
254
+ // to be rid of it. The key was revoked already, so nothing can use it;
255
+ // deleting hides it from the owner and tells an admin it was deleted.
242
256
  await this.db
243
- .delete(externalApiKeys)
257
+ .update(externalApiKeys)
258
+ .set({ deletedAt: new Date() })
244
259
  .where(
245
260
  and(
246
261
  eq(externalApiKeys.id, id),
247
262
  eq(externalApiKeys.userId, userId),
248
263
  isNotNull(externalApiKeys.revokedAt),
264
+ isNull(externalApiKeys.deletedAt),
249
265
  ),
250
266
  );
251
267
  }
@@ -271,5 +287,6 @@ function toSummary(row: typeof externalApiKeys.$inferSelect): ExternalApiKeySumm
271
287
  lastUsedAt: row.lastUsedAt ? row.lastUsedAt.getTime() : null,
272
288
  revokedAt: row.revokedAt ? row.revokedAt.getTime() : null,
273
289
  revokedBy: (row.revokedBy as RevokedBy | null) ?? null,
290
+ deletedAt: row.deletedAt ? row.deletedAt.getTime() : null,
274
291
  };
275
292
  }
@@ -37,6 +37,16 @@ export interface InternalTokenClaim {
37
37
  * expected to carry `sessionId` on the tool body, not in the token.
38
38
  */
39
39
  externalProxy?: boolean;
40
+ /**
41
+ * The agent connection (`agent_connections.id`) an external-proxy token
42
+ * stands in for. The LOCAL MCP server swaps its OAuth grant for one of these
43
+ * at `/api/mcp/local-token` and presents it everywhere the grant would have
44
+ * gone, so without this claim every call it makes would arrive with a user
45
+ * and no agent — unattributable in the Audit log. Carried, never decided:
46
+ * the exchange copies it from the verified grant. Absent on every other
47
+ * internal token.
48
+ */
49
+ connectionId?: string;
40
50
  }
41
51
 
42
52
  interface SignedPayload extends InternalTokenClaim {
@@ -137,6 +147,7 @@ export class InternalTokenService {
137
147
  ...(typeof payload.sessionId === 'string' ? { sessionId: payload.sessionId } : {}),
138
148
  ...(typeof payload.focusedBranch === 'string' ? { focusedBranch: payload.focusedBranch } : {}),
139
149
  ...(payload.externalProxy === true ? { externalProxy: true } : {}),
150
+ ...(typeof payload.connectionId === 'string' ? { connectionId: payload.connectionId } : {}),
140
151
  };
141
152
  }
142
153
 
@@ -34,6 +34,14 @@ export interface ToolAuth {
34
34
  * never carries it and must name the branch explicitly.
35
35
  */
36
36
  focusedBranch?: string;
37
+ /**
38
+ * The agent connection an `externalProxy` token stands in for — the local
39
+ * server's exchanged grant names one (see `InternalTokenClaim.connectionId`).
40
+ * Only that grant carries it: the hosted proxy's own loopback tokens do
41
+ * not, and a connection key has a `tokenId` instead. What the REST audit
42
+ * recorder attributes a direct tool call to.
43
+ */
44
+ connectionId?: string;
37
45
  /** Always `'write'` now (the middleware sets it for both internal + external — neither credential carries scope). The read path is dormant until consumer agents are removed. */
38
46
  scope: 'read' | 'write';
39
47
  }
@@ -129,7 +137,15 @@ export function createTokenVerifier(
129
137
  const claim = internalTokenService.verify(token);
130
138
  if (!claim) return { ok: false, status: 401, message: 'Invalid or expired internal token' };
131
139
  return claim.externalProxy
132
- ? { ok: true, auth: { source: 'external', userId: claim.userId, scope: 'write' } }
140
+ ? {
141
+ ok: true,
142
+ auth: {
143
+ source: 'external',
144
+ userId: claim.userId,
145
+ ...(claim.connectionId ? { connectionId: claim.connectionId } : {}),
146
+ scope: 'write',
147
+ },
148
+ }
133
149
  : { ok: true, auth: { source: 'internal', userId: claim.userId, sessionId: claim.sessionId, focusedBranch: claim.focusedBranch, scope: 'write' } };
134
150
  }
135
151
 
@@ -59,15 +59,50 @@ function fileForms(name: string): Record<string, string> {
59
59
  'leading slash': `/${KB}/.git/${name}`,
60
60
  dotted: `${KB}/Notes/../.git/${name}`,
61
61
  'dot segments': `./${KB}/./.git/${name}`,
62
+ // The five families production measured answering the path rule's 400
63
+ // instead of this rule's 403 (parent-climb, dot-segment, backslash,
64
+ // climb-out, absolute). `dotted`, `dot segments` and `backslashed` above
65
+ // are three of them; these are the two the set was missing.
66
+ 'climb-out': `../${KB}/.git/${name}`,
67
+ // Production's own spelling, kept verbatim: it names a directory that
68
+ // exists on no test machine, so it is the LEXICAL half this covers.
69
+ absolute: `/app/apps/server/workspaces/main/${KB}/.git/${name}`,
70
+ // The same family pointed at this run's workspace, so the resolved half
71
+ // is exercised too — the folder it names is really there.
72
+ 'absolute, in workspace': `{{WORKSPACE_DIR}}/${KB}/.git/${name}`,
62
73
  encoded: `${KB}/%2egit/${name}`,
63
74
  'double-encoded': `${KB}/%252Egit/${name}`,
64
75
  'upper-case': `${KB}/.GIT/${name}`,
65
76
  'mixed-case': `${KB}/.Git/${name}`,
66
77
  backslashed: `${KB}\\.git\\${name}`,
67
78
  symlinked: `${KB}/gitlink/${name}`,
79
+ // A LINK into the folder, reached through each of those same five
80
+ // spellings. Nothing in these names is `.git`, so only the resolved half
81
+ // of the rule sees them — and while that half ran after the normaliser,
82
+ // every one of them was answered by the path rule instead. This is the
83
+ // shape that has now broken twice; it is spelled out per family so a
84
+ // failure names the one that regressed.
85
+ 'symlinked, parent-climb': `${KB}/Notes/../gitlink/${name}`,
86
+ 'symlinked, dot segments': `./${KB}/./gitlink/${name}`,
87
+ 'symlinked, backslashed': `${KB}\\gitlink\\${name}`,
88
+ 'symlinked, climb-out': `../${KB}/gitlink/${name}`,
89
+ 'symlinked, absolute': `{{WORKSPACE_DIR}}/${KB}/gitlink/${name}`,
90
+ 'symlinked, chained link': `${KB}/Notes/../chained/${name}`,
91
+ // A link named `..link` sitting in the WORKSPACE root: relative to the
92
+ // root this rule judges against, the spelling begins with `..`, which is
93
+ // the one shape a `startsWith('..')` climb test swallows. Spelled from
94
+ // anywhere else (`knowledge-base/..link/…`) it never begins with `..` and
95
+ // pins nothing.
96
+ 'symlinked, dotted name at the root': `..link/${name}`,
68
97
  };
69
98
  }
70
99
 
100
+ /**
101
+ * The absolute forms name the workspace on disk, which only exists once the
102
+ * temp root does — so they carry a placeholder until the test runs.
103
+ */
104
+ const resolved = (form: string): string => form.replace('{{WORKSPACE_DIR}}', workspaceDir);
105
+
71
106
  /** Every spelling of the git folder ITSELF, for the operations that take a directory. */
72
107
  const DIR_FORMS: Record<string, string> = {
73
108
  plain: `${KB}/.git`,
@@ -77,6 +112,20 @@ const DIR_FORMS: Record<string, string> = {
77
112
  'upper-case': `${KB}/.GIT`,
78
113
  symlinked: `${KB}/gitlink`,
79
114
  'symlinked, chained': `${KB}/chained`,
115
+ 'symlinked, parent-climb': `${KB}/Notes/../gitlink`,
116
+ 'symlinked, dot segments': `./${KB}/./gitlink`,
117
+ 'symlinked, backslashed': `${KB}\\gitlink`,
118
+ 'symlinked, climb-out': `../${KB}/gitlink`,
119
+ 'symlinked, absolute': `{{WORKSPACE_DIR}}/${KB}/gitlink`,
120
+ 'symlinked, dotted name at the root': `..link`,
121
+ // The folder ITSELF in the same five families the file forms carry, so every
122
+ // directory-taking operation — list, mkdir, delete, folder download, unzip
123
+ // destination — has a regression case for each of them too.
124
+ 'parent-climb': `${KB}/Notes/../.git`,
125
+ 'dot segments': `./${KB}/./.git`,
126
+ backslashed: `${KB}\\.git`,
127
+ 'climb-out': `../${KB}/.git`,
128
+ absolute: `{{WORKSPACE_DIR}}/${KB}/.git`,
80
129
  };
81
130
 
82
131
  const FILE_FORMS = { ...fileForms('config'), 'symlinked file': `${KB}/cfglink` };
@@ -107,6 +156,9 @@ beforeEach(async () => {
107
156
  await symlink('.git', join(kb, 'gitlink'));
108
157
  await symlink('.git/config', join(kb, 'cfglink'));
109
158
  await symlink('gitlink', join(kb, 'chained'));
159
+ // An ordinary name that merely BEGINS with dots — not a climb, and a link by
160
+ // that name reaches the folder like any other.
161
+ await symlink(join(KB, '.git'), join(workspaceDir, '..link'));
110
162
  const zip = new AdmZip();
111
163
  zip.addFile('extracted.md', Buffer.from('# extracted\n'));
112
164
  zip.writeZip(join(kb, 'archive.zip'));
@@ -153,28 +205,27 @@ async function listen(app: express.Express): Promise<{ server: Server; baseUrl:
153
205
  }
154
206
 
155
207
  /**
156
- * The spellings the PATH rule refuses before the git rule is ever consulted:
157
- * a `..` segment, a `.` segment below a leading `./`, a backslash. One
158
- * normaliser reads every accepted workspace path now, and it refuses these as
159
- * paths rather than as git paths — earlier, and with the 400 that says the path
160
- * could not be placed inside the repository. The git folder is unreachable
161
- * either way, which is what this file is about; only the sentence differs.
162
- */
163
- const UNSPELLABLE = new Set(['dotted', 'dot segments', 'backslashed']);
164
-
165
- /**
166
- * The refusal, whichever rule got there first. Pass the form's name and a
167
- * spelling the normaliser refuses is checked against ITS answer; leave it out
168
- * and only the git refusal will do.
208
+ * The ONE refusal, for every spelling.
209
+ *
210
+ * This used to make an exception: the path normaliser refuses a `..` segment,
211
+ * a `.` segment, a backslash and an absolute path as PATHS, and for a while
212
+ * that answer — a 400 quoting the spelling back — reached those spellings of a
213
+ * git path first. Production measured it on all thirteen file tools. The git
214
+ * rule now reads the caller's raw spelling before anything may rewrite or
215
+ * refuse it, so no form is answered by anything but the sanitized 403; the
216
+ * `form` argument is kept so a failure names the spelling that broke.
169
217
  */
170
218
  async function expectRefused(res: Response, form?: string): Promise<unknown> {
171
219
  const body = (await res.json()) as Record<string, unknown>;
172
- if (form !== undefined && UNSPELLABLE.has(form)) {
173
- expect({ status: res.status, outside: /is outside the knowledge base repository/.test(String(body.error)) }, form)
174
- .toEqual({ status: 400, outside: true });
175
- return body;
176
- }
177
- expect({ status: res.status, error: body.error }).toEqual({ status: 403, error: GIT_INTERNALS_MESSAGE });
220
+ expect(res.status, form).toBe(403);
221
+ // The WHOLE body, not a search through it: the sanitized message stands
222
+ // alone, and no field beside it can carry a spelling of the caller's path or
223
+ // a "use this instead" correction. Two shapes exist — the tool surface
224
+ // answers `{ error }`, the routes add the error's `kind` — so the one the
225
+ // answer claims is the one it is held to, exactly.
226
+ expect(body, form).toEqual(
227
+ 'kind' in body ? { kind: 'git-internals', error: GIT_INTERNALS_MESSAGE } : { error: GIT_INTERNALS_MESSAGE },
228
+ );
178
229
  return body;
179
230
  }
180
231
 
@@ -276,14 +327,15 @@ describe('workspace tools refuse the git folder', () => {
276
327
  for (const [op, args] of fileOps) {
277
328
  const tool = op.split(' ')[0];
278
329
  describe(op, () => {
279
- it.each(Object.entries(FILE_FORMS))('%s form', async (form, p) => {
330
+ it.each(Object.entries(FILE_FORMS))('%s form', async (form, raw) => {
331
+ const p = resolved(raw);
280
332
  await expectRefused(await call(tool, args(p)), form);
281
333
  });
282
334
 
283
335
  it('answers a missing path exactly as an existing one', async () => {
284
336
  for (const form of Object.keys(MISSING_FORMS)) {
285
- const existing = await call(tool, args(fileForms('config')[form]));
286
- const missing = await call(tool, args(MISSING_FORMS[form]));
337
+ const existing = await call(tool, args(resolved(fileForms('config')[form])));
338
+ const missing = await call(tool, args(resolved(MISSING_FORMS[form])));
287
339
  expect(missing.status, form).toBe(existing.status);
288
340
  // Compared with the file name put back. A spelling the PATH rule
289
341
  // refuses is answered on the spelling alone, and that answer QUOTES
@@ -301,7 +353,8 @@ describe('workspace tools refuse the git folder', () => {
301
353
 
302
354
  for (const [op, args] of dirOps) {
303
355
  const tool = op.split(' ')[0];
304
- it.each(Object.entries(DIR_FORMS))(`${op} — %s form`, async (form, p) => {
356
+ it.each(Object.entries(DIR_FORMS))(`${op} — %s form`, async (form, raw) => {
357
+ const p = resolved(raw);
305
358
  await expectRefused(await call(tool, args(p)), form);
306
359
  });
307
360
  }
@@ -444,14 +497,15 @@ describe('workspace routes refuse the git folder', () => {
444
497
  // No form passed: on this surface the git guard is mounted ahead of every
445
498
  // handler on the `/workspace/:id` prefix, so it answers before the
446
499
  // normaliser is ever asked — the one 403, in every spelling, as before.
447
- it.each(Object.entries(FILE_FORMS))('%s form', async (_form, p) => {
500
+ it.each(Object.entries(FILE_FORMS))('%s form', async (_form, raw) => {
501
+ const p = resolved(raw);
448
502
  await expectRefused(await send(p));
449
503
  });
450
504
 
451
505
  it('answers a missing path exactly as an existing one', async () => {
452
506
  for (const form of Object.keys(MISSING_FORMS)) {
453
- const existing = await send(fileForms('config')[form]);
454
- const missing = await send(MISSING_FORMS[form]);
507
+ const existing = await send(resolved(fileForms('config')[form]));
508
+ const missing = await send(resolved(MISSING_FORMS[form]));
455
509
  expect(missing.status).toBe(existing.status);
456
510
  expect(await missing.json()).toEqual(await existing.json());
457
511
  }
@@ -460,7 +514,8 @@ describe('workspace routes refuse the git folder', () => {
460
514
  }
461
515
 
462
516
  for (const [name, send] of dirRoutes) {
463
- it.each(Object.entries(DIR_FORMS))(`${name} — %s form`, async (_form, p) => {
517
+ it.each(Object.entries(DIR_FORMS))(`${name} — %s form`, async (_form, raw) => {
518
+ const p = resolved(raw);
464
519
  await expectRefused(await send(p));
465
520
  });
466
521
  }
@@ -533,23 +588,58 @@ describe('WorkspaceService refuses the git folder on its own', () => {
533
588
  ];
534
589
 
535
590
  for (const [name, run] of ops) {
536
- it.each(Object.entries(fileForms('config')).filter(([form]) => !(name === 'readFileAtRef' && form === 'symlinked')))(
591
+ // `readFileAtRef` reads a path out of a git REF, never off the working
592
+ // tree, so no link on disk is followed and the link spellings do not
593
+ // apply to it — a ref read of a link's path yields the link's own blob
594
+ // (the target's NAME), never anything from inside the folder.
595
+ it.each(
596
+ Object.entries(fileForms('config')).filter(
597
+ ([form]) => !(name === 'readFileAtRef' && form.startsWith('symlinked')),
598
+ ),
599
+ )(
537
600
  `${name} — %s form`,
538
- async (form, p) => {
539
- const err = await run(p).catch((e: unknown) => e);
540
- // `readFileAtRef` takes a REPO-relative path (it strips the prefix
541
- // above), so it is not a workspace path and does not meet the
542
- // normaliser; every other op does, and for the spellings the path rule
543
- // refuses that refusal is the one that answers.
544
- if (UNSPELLABLE.has(form) && name !== 'readFileAtRef') {
545
- expect((err as Error).message, form).toMatch(/is outside the knowledge base repository/);
546
- return;
547
- }
548
- expect(err).toBeInstanceOf(GitInternalsError);
601
+ async (form, raw) => {
602
+ const err = await run(resolved(raw)).catch((e: unknown) => e);
603
+ expect(err, form).toBeInstanceOf(GitInternalsError);
549
604
  },
550
605
  );
551
606
  }
552
607
 
608
+ it('a ref read of a link path reads the REF, not the folder the link points into', async () => {
609
+ // The exception the matrix above excludes, asserted rather than assumed:
610
+ // `readFileAtRef` runs `git show <ref>:<path>`, which reads out of the
611
+ // object database and never follows a link on disk. A ref read of a link's
612
+ // path yields the link's own blob — the target's NAME — so there is
613
+ // nothing from inside the folder to refuse, and refusing it would take a
614
+ // legitimate read away. The git rule still refuses every LITERAL spelling.
615
+ const gitRunner = {
616
+ defaultTimeoutMs: 1000,
617
+ run: vi.fn(async (_cwd: string, args: string[]) => {
618
+ // What `git show` really answers for a link's path: its target name.
619
+ expect(args[0]).toBe('show');
620
+ return { stdout: '.git\n', stderr: '', code: 0 };
621
+ }),
622
+ };
623
+ const refService = new WorkspaceService(root, 'https://example.invalid/kb.git', KB, new NodeFs(), 'x-access-token', gitRunner as never);
624
+
625
+ for (const [form, raw] of Object.entries(fileForms('config'))) {
626
+ if (!form.startsWith('symlinked')) continue;
627
+ // The service takes a repo-relative path here, as its callers pass it.
628
+ const repoPath = resolved(raw).replace(`${KB}/`, '');
629
+ const result = await refService.readFileAtRef(WS, 'main', repoPath).catch((e: unknown) => e);
630
+ expect(result, form).not.toBeInstanceOf(GitInternalsError);
631
+ }
632
+ expect(gitRunner.run).toHaveBeenCalled();
633
+
634
+ // …while a literal git path at a ref is still refused, whatever its form.
635
+ for (const [form, raw] of Object.entries(fileForms('config'))) {
636
+ if (form.startsWith('symlinked')) continue;
637
+ const repoPath = resolved(raw).replace(`${KB}/`, '');
638
+ const err = await refService.readFileAtRef(WS, 'main', repoPath).catch((e: unknown) => e);
639
+ expect(err, form).toBeInstanceOf(Error);
640
+ }
641
+ });
642
+
553
643
  it('a folder download leaves out a git folder spelled in another case', async () => {
554
644
  const upper = join(workspaceDir, KB, 'Notes', '.GIT');
555
645
  await mkdir(upper, { recursive: true });
@@ -641,7 +731,7 @@ describe('the route guard on its own', () => {
641
731
 
642
732
  it('refuses every spelling before the route runs, even unauthenticated', async () => {
643
733
  userId = undefined;
644
- const spellings = Object.values(FILE_FORMS);
734
+ const spellings = Object.values(FILE_FORMS).map(resolved);
645
735
  for (const p of spellings) {
646
736
  const res = await fetch(`${baseUrl}/api/workspace/${WS}/anything?path=${encodeURIComponent(p)}`);
647
737
  // Every spelling that NAMES the folder is refused with no disk touched.
@@ -702,7 +792,8 @@ describe('DiffService refuses the git folder on its own', () => {
702
792
  ];
703
793
 
704
794
  for (const [name, run] of ops) {
705
- it.each(Object.entries(FILE_FORMS))(`${name} — %s form`, async (_form, p) => {
795
+ it.each(Object.entries(FILE_FORMS))(`${name} — %s form`, async (_form, raw) => {
796
+ const p = resolved(raw);
706
797
  await expect(run(p)).rejects.toBeInstanceOf(GitInternalsError);
707
798
  });
708
799
  }
@@ -740,7 +831,7 @@ describe('agent filesystems refuse the git folder on their own', () => {
740
831
  { basePath: workspaceDir, contained: true },
741
832
  { workflow: workflow as unknown as IWorkflowService, workspaceId: WS, branch: BRANCH, user: USER, kbDirName: KB },
742
833
  );
743
- for (const p of Object.values(FILE_FORMS)) {
834
+ for (const p of Object.values(FILE_FORMS).map(resolved)) {
744
835
  await expect(lockingFs.readFile(p)).rejects.toBeInstanceOf(GitInternalsError);
745
836
  await expect(lockingFs.stat(p)).rejects.toBeInstanceOf(GitInternalsError);
746
837
  await expect(lockingFs.writeFile(p, 'x')).rejects.toBeInstanceOf(GitInternalsError);
@@ -750,7 +841,7 @@ describe('agent filesystems refuse the git folder on their own', () => {
750
841
  await expect(lockingFs.moveFile(p, `${KB}/Notes/b.md`)).rejects.toBeInstanceOf(GitInternalsError);
751
842
  await expect(lockingFs.writeFiles([{ path: p, content: 'x' }], 'batch')).rejects.toBeInstanceOf(GitInternalsError);
752
843
  }
753
- for (const p of Object.values(DIR_FORMS)) {
844
+ for (const p of Object.values(DIR_FORMS).map(resolved)) {
754
845
  await expect(lockingFs.readdir(p)).rejects.toBeInstanceOf(GitInternalsError);
755
846
  await expect(lockingFs.mkdir(`${p}/new`)).rejects.toBeInstanceOf(GitInternalsError);
756
847
  }
@@ -760,7 +851,7 @@ describe('agent filesystems refuse the git folder on their own', () => {
760
851
 
761
852
  it('ReadOnlyFilesystem answers a git path with the git refusal, not the read-only one', async () => {
762
853
  const readOnly = new ReadOnlyFilesystem({ basePath: workspaceDir, contained: true });
763
- for (const p of Object.values(FILE_FORMS)) {
854
+ for (const p of Object.values(FILE_FORMS).map(resolved)) {
764
855
  await expect(readOnly.readFile(p)).rejects.toBeInstanceOf(GitInternalsError);
765
856
  await expect(readOnly.exists(p)).rejects.toBeInstanceOf(GitInternalsError);
766
857
  await expect(readOnly.writeFile(p, 'x')).rejects.toBeInstanceOf(GitInternalsError);
@@ -995,6 +995,14 @@ export class WorkspaceService implements IWorkspaceService {
995
995
  * is the path the operation will use, and a spelling that normalises inside
996
996
  * and resolves outside is exactly the miss this check exists to catch.
997
997
  *
998
+ * The git rule reads the caller's RAW spelling before either step — the
999
+ * whole rule, links resolved, not just the spelling. Those two refuse
1000
+ * `.`/`..`, backslashes and absolute paths with a message that quotes the
1001
+ * path back and names a corrected one, and a git path spelled any of those
1002
+ * ways was answered by THAT rather than by the one sanitized refusal
1003
+ * (measured in production), as was a LINK into the folder spelled the same
1004
+ * ways. Which rule speaks is not the caller's to choose.
1005
+ *
998
1006
  * Callers use the returned `relativePath` for everything downstream — the
999
1007
  * git-internals check, the diff baseline, the lock-free path turns, their own
1000
1008
  * error messages — so what is checked is what is written. Nine inline
@@ -1006,6 +1014,7 @@ export class WorkspaceService implements IWorkspaceService {
1006
1014
  wsPath: string,
1007
1015
  ): Promise<{ workspaceDir: string; relativePath: string; absolutePath: string }> {
1008
1016
  const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
1017
+ await assertNotGitInternals(workspaceDir, wsPath); // The RAW spelling, first — see above.
1009
1018
  const relativePath = normalizeWorkspacePath(wsPath, this.kbDirName);
1010
1019
  const absolutePath = path.resolve(workspaceDir, relativePath);
1011
1020
  assertWithinDirectory(absolutePath, this.repoRoot(workspaceDir));
@@ -23,7 +23,7 @@ import { workspaceIdForBranch } from '../../shared/workspace-id.js';
23
23
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
24
24
  import { assertInsideRepo, assertRepoRootNameFreeArgs, normalizePathArgs } from '../kb-fs/repo-path.js';
25
25
  import { GitGuardedFilesystem } from '../kb-fs/git-guarded-filesystem.js';
26
- import { assertNoGitInternalsSegment, hasGitInternalsSegment } from '../../shared/git-internals.js';
26
+ import { assertNoGitInternalsSegment, assertNotGitInternals, hasGitInternalsSegment } from '../../shared/git-internals.js';
27
27
  import { isRolesYamlPath } from '../access-model/roles-yaml-guard.js';
28
28
  import type { ISessionSink } from './session-sink.js';
29
29
  import { isAbsence } from '../../shared/fs.contract.js';
@@ -782,7 +782,7 @@ export function registerWorkspaceTools(
782
782
  * not cloned yet (or that does not resolve) is left to the handler; the
783
783
  * filesystem refuses again underneath regardless.
784
784
  */
785
- const assertToolPathsNotGitInternals = async (args: Record<string, unknown>, ctx: ToolContext): Promise<void> => {
785
+ const toolPathArgs = (args: Record<string, unknown>): string[] => {
786
786
  const paths: string[] = [];
787
787
  for (const key of ['path', 'src', 'dest', 'destination'] as const) {
788
788
  if (typeof args[key] === 'string') paths.push(args[key]);
@@ -793,18 +793,52 @@ export function registerWorkspaceTools(
793
793
  if (typeof fp === 'string') paths.push(fp);
794
794
  }
795
795
  }
796
- for (const p of paths) assertNoGitInternalsSegment(p);
797
- const onDisk = paths.filter((p) => !spillStore.isSpillRef(p));
798
- if (onDisk.length === 0 || typeof args.branch !== 'string' || args.branch === '') return;
799
- let fs: LocalFilesystem;
796
+ return paths;
797
+ };
798
+
799
+ /**
800
+ * The branch's workspace root, to judge a spelling against what is on disk
801
+ * — or null when there is nothing to judge it against yet. Only a branch
802
+ * ALREADY cloned is used: bootstrapping one here would clone before the
803
+ * handler's access and ontology gates have had their say.
804
+ */
805
+ const gitCheckRootFor = async (args: Record<string, unknown>, ctx: ToolContext): Promise<string | null> => {
806
+ if (typeof args.branch !== 'string' || args.branch === '') return null;
800
807
  try {
801
- if (!(await ctx.workspaceService.hasBootstrappedWorkspace(workspaceIdForBranch(args.branch)))) return;
802
- fs = await ctx.getFilesystem(args.branch);
808
+ if (!(await ctx.workspaceService.hasBootstrappedWorkspace(workspaceIdForBranch(args.branch)))) return null;
809
+ const fs = await ctx.getFilesystem(args.branch);
810
+ return fs instanceof GitGuardedFilesystem ? fs.basePath : null;
803
811
  } catch {
804
- return;
812
+ return null;
805
813
  }
806
- if (!(fs instanceof GitGuardedFilesystem)) return;
807
- for (const p of onDisk) await fs.assertNotGitInternals(p);
814
+ };
815
+
816
+ /**
817
+ * The WHOLE rule — the spelling and where it lands, links resolved — over
818
+ * the caller's own arguments, before any gate, lock or read.
819
+ *
820
+ * Run before `normalizePathArgs`, which refuses a `..` segment, a `.`
821
+ * segment, a backslash and an absolute path as PATHS: a 400 that quotes the
822
+ * spelling back and names a corrected one. That answer used to arrive first
823
+ * for five families of spelling, so `knowledge-base/Notes/../.git/config`
824
+ * was told which path it meant instead of being refused. Running only the
825
+ * LEXICAL half here fixed those and left the same hole one step along: a
826
+ * link into the folder (`Notes/../gitlink/config`) has no `.git` to read in
827
+ * its spelling, so it took the path rule's answer too. Both halves therefore
828
+ * read the caller's spelling before anything may rewrite or refuse it.
829
+ *
830
+ * Nothing under the folder was reachable through any of it — the filesystem
831
+ * refuses again underneath — but which rule answers is not the caller's to
832
+ * choose by how they spell the path.
833
+ */
834
+ const assertToolPathsNotGitInternals = async (args: Record<string, unknown>, ctx: ToolContext): Promise<void> => {
835
+ const paths = toolPathArgs(args);
836
+ for (const p of paths) assertNoGitInternalsSegment(p);
837
+ const onDisk = paths.filter((p) => !spillStore.isSpillRef(p));
838
+ if (onDisk.length === 0) return;
839
+ const root = await gitCheckRootFor(args, ctx);
840
+ if (root === null) return;
841
+ for (const p of onDisk) await assertNotGitInternals(root, p);
808
842
  };
809
843
 
810
844
  // ── preflight for moves and deletes ─────────────────────────────────────
@@ -1188,6 +1222,8 @@ export function registerWorkspaceTools(
1188
1222
  // never reached the repository — the whole bug, spelled with a prefix.
1189
1223
  toolHandler(
1190
1224
  async (args, ctx) => {
1225
+ // BEFORE the normaliser: see `assertToolPathsNotGitInternals`.
1226
+ if (spec.fileTool !== false) await assertToolPathsNotGitInternals(args, ctx);
1191
1227
  const normalized = normalizePathArgs(
1192
1228
  args,
1193
1229
  kbDirName,
@@ -98,6 +98,47 @@ describe('assertNotGitInternals — the resolved form', () => {
98
98
  await expect(assertNotGitInternals(root, 'knowledge-base/dangling')).rejects.toMatchObject({ code: 'EACCES' });
99
99
  });
100
100
 
101
+ it.each(['/etc/shadow', '../../../../etc/shadow', '/proc/1/root/secret'])(
102
+ 'never probes a spelling that lands outside the workspace on disk: %s',
103
+ async (outside) => {
104
+ // A raw spelling fans out into candidates, and one can name somewhere
105
+ // the server may not read. Probing it would answer with that place's own
106
+ // EACCES instead of the path rule's typed refusal for a spelling that
107
+ // was never a workspace path — so the disk is only asked about
108
+ // candidates that land under the root. The lexical reading still judges
109
+ // the rest, which is what catches a climb into another checkout's .git.
110
+ const realpath = fs.realpath.bind(fs);
111
+ const asked: string[] = [];
112
+ vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) => {
113
+ asked.push(p);
114
+ if (!p.startsWith(root)) throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
115
+ return realpath(p);
116
+ }) as typeof fs.realpath);
117
+
118
+ await expect(assertNotGitInternals(root, outside)).resolves.toBeUndefined();
119
+ expect(asked.filter((p) => !p.startsWith(root))).toEqual([]);
120
+ },
121
+ );
122
+
123
+ it.each(['..link', '..link/config', './..link/config', '..dir/deeper'])(
124
+ 'probes a name that merely BEGINS with dots, and refuses it when it lands in the folder: %s',
125
+ async (p) => {
126
+ // Judged from the folder these sit DIRECTLY in, which is where the
127
+ // outside-the-root test can mistake them: relative to that root, the
128
+ // whole path is `..link/…`. `..link` is not a climb — it is a file name
129
+ // — so it is probed like any other entry, or a link by that name reaches
130
+ // the folder unchecked.
131
+ const kb = path.join(root, 'knowledge-base');
132
+ await fs.symlink('.git', path.join(kb, '..link'));
133
+ await fs.symlink('.git', path.join(kb, '..dir'));
134
+ await expect(assertNotGitInternals(kb, p)).rejects.toBeInstanceOf(GitInternalsError);
135
+ },
136
+ );
137
+
138
+ it('still refuses a climb into another checkout’s git folder, which is judged without the disk', async () => {
139
+ await expect(assertNotGitInternals(root, '../other-checkout/.git/config')).rejects.toBeInstanceOf(GitInternalsError);
140
+ });
141
+
101
142
  it('a link loop is no refusal of its own, and does not hang', async () => {
102
143
  await expect(assertNotGitInternals(root, 'knowledge-base/loop-a')).resolves.toBeUndefined();
103
144
  });