@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
@@ -48,6 +48,8 @@ function makeExternalApiKeyService(
48
48
 
49
49
  function makeOAuthProvider(
50
50
  verify?: (t: string) => Promise<{ extra?: Record<string, unknown> }>,
51
+ /** Whether an agent connection still admits requests — true unless a test says otherwise. */
52
+ live?: (connectionId: string) => Promise<boolean>,
51
53
  ) {
52
54
  return {
53
55
  looksLikeAccessToken: (t: string) => typeof t === 'string' && t.startsWith('bevel-mcp_'),
@@ -57,6 +59,8 @@ function makeOAuthProvider(
57
59
  throw new InvalidTokenError('unknown token');
58
60
  }),
59
61
  ),
62
+ isConnectionLive: vi.fn(live ?? (async () => true)),
63
+ noteConnectionUse: vi.fn(),
60
64
  } as unknown as BevelOAuthProvider;
61
65
  }
62
66
 
@@ -179,7 +183,7 @@ describe('createMcpAuthMiddleware', () => {
179
183
  const auth = makeAuthService();
180
184
  const externalApiKeys = makeExternalApiKeyService();
181
185
  const oauth = makeOAuthProvider(async () => ({
182
- extra: { userId: 'user-5', userEmail: 'eve@example.com' },
186
+ extra: { userId: 'user-5', userEmail: 'eve@example.com', connectionId: 'conn-5' },
183
187
  }));
184
188
  const mw = makeMw({ auth, keys: externalApiKeys, oauth });
185
189
  const { req, res, next } = makeReqRes('Bearer bevel-mcp_token123');
@@ -188,14 +192,28 @@ describe('createMcpAuthMiddleware', () => {
188
192
 
189
193
  expect(req.userId).toBe('user-5');
190
194
  expect(req.userEmail).toBe('eve@example.com');
191
- // OAuth sessions are unmetered like JWT sessions — no connection key id.
195
+ // OAuth sessions are unmetered like JWT sessions — no connection key id…
192
196
  expect(req.externalApiKeyId).toBeUndefined();
197
+ // …but they ARE attributed to the agent behind the grant, for the Audit log.
198
+ expect(req.agentConnectionId).toBe('conn-5');
193
199
  expect(next).toHaveBeenCalled();
194
200
  // Neither the connection-key nor the JWT path may see this token shape.
195
201
  expect((externalApiKeys as any).verifyAndLoadToken).not.toHaveBeenCalled();
196
202
  expect((auth as any).verifyToken).not.toHaveBeenCalled();
197
203
  });
198
204
 
205
+ it('binds no agent for an OAuth token minted before connections existed, and still admits it', async () => {
206
+ const oauth = makeOAuthProvider(async () => ({
207
+ extra: { userId: 'user-5', userEmail: 'eve@example.com', connectionId: null },
208
+ }));
209
+ const mw = makeMw({ oauth });
210
+ const { req, res, next } = makeReqRes('Bearer bevel-mcp_token123');
211
+ await mw(req, res, next);
212
+ expect(req.userId).toBe('user-5');
213
+ expect(req.agentConnectionId).toBeUndefined();
214
+ expect(next).toHaveBeenCalled();
215
+ });
216
+
199
217
  it('401s with the discovery challenge when the OAuth token is invalid/expired/revoked', async () => {
200
218
  const mw = makeMw();
201
219
  const { req, res, next, setHeader, status } = makeReqRes('Bearer bevel-mcp_expired');
@@ -276,6 +294,46 @@ describe('createMcpAuthMiddleware', () => {
276
294
  expect(next).toHaveBeenCalled();
277
295
  expect(req.userId).toBe('user-7');
278
296
  expect(req.userEmail).toBe('seven@example.com');
297
+ // A plain loopback identity names no agent.
298
+ expect(req.agentConnectionId).toBeUndefined();
299
+ });
300
+
301
+ it('carries the agent connection of an exchanged grant, so the local server stays attributed', async () => {
302
+ const token = internal.mint({ userId: 'user-7', externalProxy: true, connectionId: 'conn-7' }, 60_000);
303
+ const auth = makeAuthService();
304
+ (auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({
305
+ id: 'user-7',
306
+ email: 'seven@example.com',
307
+ }));
308
+ const oauth = makeOAuthProvider();
309
+ const mw = makeMw({ internal, auth, oauth });
310
+ const { req, res, next } = makeReqRes(`Bearer ${token}`);
311
+ await mw(req, res, next);
312
+ expect(next).toHaveBeenCalled();
313
+ expect(req.agentConnectionId).toBe('conn-7');
314
+ // Every request through the exchanged grant asks whether the
315
+ // connection still admits one, and counts as a use of it.
316
+ expect((oauth as any).isConnectionLive).toHaveBeenCalledWith('conn-7');
317
+ expect((oauth as any).noteConnectionUse).toHaveBeenCalledWith('conn-7');
318
+ });
319
+
320
+ it('401s an exchanged grant whose agent connection was revoked — the token is stateless, the connection is not', async () => {
321
+ const token = internal.mint({ userId: 'user-7', externalProxy: true, connectionId: 'conn-7' }, 60_000);
322
+ const auth = makeAuthService();
323
+ (auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({
324
+ id: 'user-7',
325
+ email: 'seven@example.com',
326
+ }));
327
+ const oauth = makeOAuthProvider(undefined, async () => false);
328
+ const mw = makeMw({ internal, auth, oauth });
329
+ const { req, res, next, status, setHeader } = makeReqRes(`Bearer ${token}`);
330
+ await mw(req, res, next);
331
+ expect(next).not.toHaveBeenCalled();
332
+ expect(status).toHaveBeenCalledWith(401);
333
+ expect(setHeader).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('resource_metadata'));
334
+ expect(req.agentConnectionId).toBeUndefined();
335
+ // A refused request is no use of the connection.
336
+ expect((oauth as any).noteConnectionUse).not.toHaveBeenCalled();
279
337
  });
280
338
 
281
339
  it('401s an expired internal token', async () => {
@@ -74,7 +74,7 @@ async function exchange(baseUrl: string, authorization?: string): Promise<Respon
74
74
  describe('POST /mcp/local-token', () => {
75
75
  it('exchanges a valid OAuth access token for an internal token with the loopback identity + TTL', async () => {
76
76
  const oauth = makeOAuthProvider(async () => ({
77
- extra: { userId: 'user-5', userEmail: 'eve@example.com' },
77
+ extra: { userId: 'user-5', userEmail: 'eve@example.com', connectionId: 'conn-9' },
78
78
  // A grant with plenty of life left: the loopback constant is the binding cap.
79
79
  expiresAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
80
80
  }));
@@ -88,21 +88,41 @@ describe('POST /mcp/local-token', () => {
88
88
  // above outlives it, so the constant is the cap that binds.
89
89
  expect(body.expiresInMs).toBe(MCP_LOOPBACK_TOKEN_TTL_MS);
90
90
  // The minted token verifies to the resolved user with the externalProxy
91
- // flag — identical shape to the hosted session's loopback bearer.
92
- expect(internalTokens.verify(body.token)).toEqual({ userId: 'user-5', externalProxy: true });
91
+ // flag — identical shape to the hosted session's loopback bearer — plus
92
+ // the grant's agent connection, so the local server's calls stay
93
+ // attributed to that agent in the Audit log.
94
+ expect(internalTokens.verify(body.token)).toEqual({
95
+ userId: 'user-5',
96
+ externalProxy: true,
97
+ connectionId: 'conn-9',
98
+ });
93
99
  // …and the tool-auth verifier resolves it to source 'external', exactly
94
100
  // how /api/agent/* will treat the local server.
95
101
  const verify = createTokenVerifier(
96
102
  { looksLikeExternalApiKey: () => false } as unknown as IExternalApiKeyService,
97
103
  internalTokens,
98
104
  );
105
+ // …carrying the agent connection, which the REST recorder attributes
106
+ // the local server's direct tool calls to.
99
107
  await expect(verify(body.token)).resolves.toEqual({
100
108
  ok: true,
101
- auth: { source: 'external', userId: 'user-5', scope: 'write' },
109
+ auth: { source: 'external', userId: 'user-5', connectionId: 'conn-9', scope: 'write' },
102
110
  });
103
111
  expect((oauth as any).verifyAccessToken).toHaveBeenCalledWith('bevel-mcp_valid123');
104
112
  });
105
113
 
114
+ it('mints without a connection claim for a grant that carries none (a token from before connections existed)', async () => {
115
+ const oauth = makeOAuthProvider(async () => ({
116
+ extra: { userId: 'user-5', userEmail: 'eve@example.com' },
117
+ expiresAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
118
+ }));
119
+ const { baseUrl, internalTokens } = await mount(oauth);
120
+ const res = await exchange(baseUrl, 'Bearer bevel-mcp_valid123');
121
+ expect(res.status).toBe(200);
122
+ const body = (await res.json()) as { token: string };
123
+ expect(internalTokens.verify(body.token)).toEqual({ userId: 'user-5', externalProxy: true });
124
+ });
125
+
106
126
  /**
107
127
  * The binding that keeps the exchange from OUTLIVING its grant: an access
108
128
  * token with less life left than the loopback constant caps the minted
@@ -108,7 +108,7 @@ describe('POST /mcp (stateless)', () => {
108
108
  expect((await messagesOf(res))[0].result.serverInfo.name).toBe('stub');
109
109
  // The messages reach the service, so it can tell an initialize apart.
110
110
  expect(mcpService.createRequestServer).toHaveBeenCalledWith(
111
- { userId: 'user-A', tokenId: null, bearer: '' },
111
+ { userId: 'user-A', tokenId: null, connectionId: null, bearer: '' },
112
112
  INITIALIZE,
113
113
  );
114
114
  });
@@ -12,6 +12,7 @@ import { createManualRoutes } from '../../tool-registry/manual.routes.js';
12
12
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
13
13
  import { toolDef } from '../../tool-helpers/tool-def.js';
14
14
  import { PLATFORM_HEADER, TOOL_PREFIX_LINE } from '../../agent-instructions/index.js';
15
+ import type { AgentEventInput, IAgentEventRecorder } from '../../audit/audit.contract.js';
15
16
 
16
17
  /**
17
18
  * End-to-end proxy test: a real express app serving the registry-driven tool
@@ -43,6 +44,10 @@ async function setup(deps?: {
43
44
  readAgentPreamble?: () => Promise<string | null>;
44
45
  /** Extra echo tools to register under these names (the four KB tools, in the prefix tests). */
45
46
  extraTools?: string[];
47
+ /** The Audit log's recorder, when a test watches what gets recorded. */
48
+ auditRecorder?: IAgentEventRecorder;
49
+ /** The agent connection an OAuth caller arrived through (with `tokenId: null`). */
50
+ connectionId?: string | null;
46
51
  }) {
47
52
  const registry = new ToolRegistry();
48
53
  registry.registerExternalTool(
@@ -91,6 +96,16 @@ async function setup(deps?: {
91
96
  res.json({ text: `echo: ${b.prompt}`, sessionId: typeof b.sessionId === 'string' ? b.sessionId : 'new-sess' });
92
97
  });
93
98
  app.post('/api/agent/tools/boom', (_req, res) => res.status(500).json({ error: 'kaboom' }));
99
+ // The two the Audit log's skill classification reads through: a catalog of
100
+ // one skill, and a file read that answers for any path (registered as
101
+ // tools only by the tests that need them, via `extraTools`).
102
+ app.post('/api/agent/tools/list_skills', (_req, res) =>
103
+ res.json({ skills: [{ name: 'rfi', description: 'RFI answers', path: 'Plugins/Sales/rfi' }] }),
104
+ );
105
+ app.post('/api/agent/tools/read_file', (req, res) => {
106
+ const b = (req.body ?? {}) as { path?: string };
107
+ res.json({ content: `contents of ${b.path}` });
108
+ });
94
109
  // A live UTCP manual a catalog entry can point at — one tool, dispatched back
95
110
  // to the echo above. Mounted always (it costs one route) so a test can add a
96
111
  // MANUAL to the served catalog, not merely a tool to the registry: what a
@@ -149,10 +164,11 @@ async function setup(deps?: {
149
164
  deps?.toolManuals as any,
150
165
  undefined, // internalTokens — the fake loopback here accepts any bearer
151
166
  deps?.revokeOAuthAccess,
167
+ deps?.auditRecorder,
152
168
  );
153
169
  lastService = mcp;
154
170
  const tokenId = deps?.tokenId === undefined ? 'tok-1' : deps.tokenId;
155
- return connectClient(mcp, tokenId);
171
+ return connectClient(mcp, tokenId, deps?.connectionId ?? null);
156
172
  }
157
173
 
158
174
  /** The service `setup` built last — for a test that needs a SECOND request against the same service. */
@@ -165,9 +181,9 @@ let lastService: McpService | undefined;
165
181
  * The initialize message is passed so the server carries instructions,
166
182
  * exactly as the route's initialize request would.
167
183
  */
168
- async function connectClient(mcp: McpService, tokenId: string | null): Promise<Client> {
184
+ async function connectClient(mcp: McpService, tokenId: string | null, connectionId: string | null = null): Promise<Client> {
169
185
  const server = await mcp.createRequestServer(
170
- { userId: 'user-A', tokenId, bearer: 'bevel_testkey' },
186
+ { userId: 'user-A', tokenId, connectionId, bearer: 'bevel_testkey' },
171
187
  { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test-client', version: '0.0.0' } } },
172
188
  );
173
189
 
@@ -680,3 +696,104 @@ describe('McpService — a collision ends when its sibling is renamed away', ()
680
696
  expect(survivor.some((m) => m.startsWith('[mcp] skipping manual "notion-eu": ') && !m.includes('rewrites to'))).toBe(true);
681
697
  });
682
698
  });
699
+
700
+ describe('McpService — the Audit log records what an agent calls', () => {
701
+ /** A recorder that keeps every event, and the same thing as the proxy's collaborator type. */
702
+ function spyRecorder(): IAgentEventRecorder & { events: AgentEventInput[] } {
703
+ const events: AgentEventInput[] = [];
704
+ return { events, record: (e) => void events.push(e) };
705
+ }
706
+ const text = (res: Awaited<ReturnType<Client['callTool']>>) => (res.content as Array<{ text: string }>)[0].text;
707
+ // Recording is fire-and-forget behind the call; give the microtasks a turn.
708
+ const settle = () => new Promise((r) => setTimeout(r, 10));
709
+
710
+ it('records a direct platform tool call as a hexis capability under the connection key, with its outcome', async () => {
711
+ const recorder = spyRecorder();
712
+ const client = await setup({ auditRecorder: recorder });
713
+
714
+ await client.callTool({ name: 'ask', arguments: { body: { prompt: 'hello' } } });
715
+ await client.callTool({ name: 'boom', arguments: {} });
716
+ await settle();
717
+
718
+ expect(recorder.events).toHaveLength(2);
719
+ expect(recorder.events[0]).toMatchObject({
720
+ userId: 'user-A',
721
+ principal: { kind: 'key', id: 'tok-1' },
722
+ kind: 'capability',
723
+ manual: null,
724
+ name: 'ask',
725
+ outcome: 'ok',
726
+ });
727
+ expect(typeof recorder.events[0].durationMs).toBe('number');
728
+ // The tool answered with an error result — recorded as such, not as ok.
729
+ expect(recorder.events[1]).toMatchObject({ kind: 'capability', name: 'boom', outcome: 'error' });
730
+ });
731
+
732
+ it('records a chain as a capability AND each call made inside it', async () => {
733
+ const recorder = spyRecorder();
734
+ const client = await setup({ auditRecorder: recorder });
735
+
736
+ await client.callTool({
737
+ name: 'call_tool_chain',
738
+ arguments: { code: "KNOWLEDGE_BASE.ask({ body: { prompt: 'one' } }); return KNOWLEDGE_BASE.ask({ body: { prompt: 'two' } });" },
739
+ });
740
+ await settle();
741
+
742
+ const names = recorder.events.map((e) => `${e.kind}:${e.name}:${e.outcome}`);
743
+ // The two inner calls finish (and are recorded) before the chain itself.
744
+ expect(names).toEqual(['capability:ask:ok', 'capability:ask:ok', 'capability:call_tool_chain:ok']);
745
+ });
746
+
747
+ it('attributes an OAuth caller to its agent connection, and records nothing for a caller with neither', async () => {
748
+ const asAgent = spyRecorder();
749
+ const agent = await setup({ auditRecorder: asAgent, tokenId: null, connectionId: 'conn-1' });
750
+ await agent.callTool({ name: 'ask', arguments: { body: { prompt: 'hi' } } });
751
+ await settle();
752
+ expect(asAgent.events[0]).toMatchObject({ principal: { kind: 'agent', id: 'conn-1' }, name: 'ask' });
753
+
754
+ const nobody = spyRecorder();
755
+ const jwt = await setup({ auditRecorder: nobody, tokenId: null, connectionId: null });
756
+ await jwt.callTool({ name: 'ask', arguments: { body: { prompt: 'hi' } } });
757
+ await settle();
758
+ expect(nobody.events).toEqual([]);
759
+ });
760
+
761
+ it('records a read inside a skill folder as that skill, and a read elsewhere as the read', async () => {
762
+ const recorder = spyRecorder();
763
+ const client = await setup({ auditRecorder: recorder, extraTools: ['read_file', 'list_skills'] });
764
+
765
+ await client.callTool({ name: 'read_file', arguments: { body: { path: 'Plugins/Sales/rfi/SKILL.md' } } });
766
+ await client.callTool({ name: 'read_file', arguments: { body: { path: 'KnowledgeBase/Product/roadmap.md' } } });
767
+ await settle();
768
+
769
+ expect(recorder.events[0]).toMatchObject({ kind: 'skill', manual: 'Plugins/Sales/rfi', name: 'rfi', outcome: 'ok' });
770
+ expect(recorder.events[1]).toMatchObject({ kind: 'capability', manual: null, name: 'read_file', outcome: 'ok' });
771
+ });
772
+
773
+ it('records a prompt read as a skill, with an error outcome when the skill is unknown', async () => {
774
+ const recorder = spyRecorder();
775
+ const client = await setup({ auditRecorder: recorder });
776
+ // The harness serves no get_skill endpoint, so every prompt is unknown here.
777
+ await expect(client.getPrompt({ name: 'rfi' })).rejects.toThrow(/Unknown skill/);
778
+ await settle();
779
+ expect(recorder.events[0]).toMatchObject({ kind: 'skill', name: 'rfi', outcome: 'error' });
780
+ });
781
+
782
+ it('records a call refused for a missing sign-in as denied, without running the tool', async () => {
783
+ const recorder = spyRecorder();
784
+ const vault = { statusFor: async (_u: string, keys: string[]) => keys.map((key) => ({ key, adminConfigured: false, userConfigured: false })) };
785
+ const manuals = {
786
+ userScopedKeysForManual: async (manual: string) =>
787
+ manual === 'KNOWLEDGE_BASE' ? [{ key: 'KNOWLEDGE_BASE_API_KEY', name: 'API_KEY', label: 'Your API key', oauth: false }] : [],
788
+ };
789
+ const client = await setup({ auditRecorder: recorder, secretsVault: vault, toolManuals: manuals });
790
+
791
+ const res = await client.callTool({ name: 'ask', arguments: { body: { prompt: 'hello' } } });
792
+ await settle();
793
+
794
+ expect(text(res)).not.toMatch(/echo:/);
795
+ expect(recorder.events).toEqual([
796
+ expect.objectContaining({ kind: 'capability', name: 'ask', outcome: 'denied', durationMs: null }),
797
+ ]);
798
+ });
799
+ });
@@ -9,6 +9,7 @@ import type { InternalTokenService } from '../tool-auth/internal-token.service.j
9
9
  import { rejectConnectionKey } from '../tool-auth/connection-key-rejection.js';
10
10
  import type { BevelOAuthProvider } from './oauth/bevel-oauth-provider.js';
11
11
  import '../tool-auth/external-api-key.interface.js'; // Express Request augmentation (req.externalApiKeyId)
12
+ import '../audit/audit.contract.js'; // Express Request augmentation (req.agentConnectionId)
12
13
 
13
14
  /**
14
15
  * Auth middleware for the MCP endpoint. Accepts any of:
@@ -111,6 +112,12 @@ export function createMcpAuthMiddleware(
111
112
  const info = await oauthProvider.verifyAccessToken(token);
112
113
  req.userId = String(info.extra?.userId ?? '');
113
114
  req.userEmail = String(info.extra?.userEmail ?? '');
115
+ // The agent behind the grant, for the Audit log. A token minted
116
+ // before connections existed carries none; the call then runs
117
+ // unattributed rather than refused.
118
+ if (typeof info.extra?.connectionId === 'string') {
119
+ req.agentConnectionId = info.extra.connectionId;
120
+ }
114
121
  if (!req.userId) {
115
122
  unauthorized(res, 'Invalid access token');
116
123
  return;
@@ -176,6 +183,30 @@ export function createMcpAuthMiddleware(
176
183
  }
177
184
  req.userId = user.id;
178
185
  req.userEmail = user.email;
186
+ // The local server's exchanged grant names its agent connection (see
187
+ // InternalTokenClaim.connectionId). The token itself is stateless and
188
+ // lives up to five hours, so the connection is asked on every request
189
+ // whether it still admits the agent — revoking the agent must reach
190
+ // this path as surely as it reaches the grant's own tokens. The use is
191
+ // stamped too (throttled, off the request's path), so the Audit log's
192
+ // "last used" follows the local server's calls, not only its sign-ins.
193
+ // Carried on the request so those calls are logged under the agent.
194
+ if (claim.connectionId) {
195
+ let live: boolean;
196
+ try {
197
+ live = await oauthProvider.isConnectionLive(claim.connectionId);
198
+ } catch (err) {
199
+ log.error('agent-connection check failed:', { err });
200
+ res.status(500).json({ error: 'Authentication backend unavailable' });
201
+ return;
202
+ }
203
+ if (!live) {
204
+ unauthorized(res, 'Access for this agent was revoked');
205
+ return;
206
+ }
207
+ oauthProvider.noteConnectionUse(claim.connectionId);
208
+ req.agentConnectionId = claim.connectionId;
209
+ }
179
210
  next();
180
211
  return;
181
212
  }
@@ -115,6 +115,10 @@ export function createMcpRoutes(
115
115
  // undefined for OAuth/JWT) — resolved per request, so per-key metering
116
116
  // never depends on anything remembered from an earlier request.
117
117
  tokenId: req.externalApiKeyId ?? null,
118
+ // Agent-connection id (set for OAuth bearers and the local server's
119
+ // exchanged grant; undefined for keys/JWTs) — the Audit log's other
120
+ // principal, resolved per request like the key id.
121
+ connectionId: req.agentConnectionId ?? null,
118
122
  // The proxy authenticates its loopback calls with the SAME bearer the
119
123
  // client used here, so it acts on the request exactly as the caller would.
120
124
  bearer: extractBearer(req),
@@ -355,7 +359,13 @@ export function createMcpRoutes(
355
359
  grantRemainingMs === undefined
356
360
  ? MCP_LOOPBACK_TOKEN_TTL_MS
357
361
  : Math.min(MCP_LOOPBACK_TOKEN_TTL_MS, grantRemainingMs);
358
- const minted = internalTokens.mint({ userId, externalProxy: true }, ttlMs);
362
+ // The grant's agent connection rides along, so the local server's
363
+ // calls with this token stay attributed to the agent in the Audit log.
364
+ const connectionId = typeof info.extra?.connectionId === 'string' ? info.extra.connectionId : undefined;
365
+ const minted = internalTokens.mint(
366
+ { userId, externalProxy: true, ...(connectionId ? { connectionId } : {}) },
367
+ ttlMs,
368
+ );
359
369
  // The ACTUAL lifetime, not the constant — the caller schedules its
360
370
  // proactive renewal off this number.
361
371
  res.json({ token: minted, expiresInMs: ttlMs });
@@ -54,6 +54,8 @@ import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.j
54
54
  import type { SpillStore } from '../workspace/spill-store.js';
55
55
  import { seedBevelHostedManualVars } from '../../shared/utcp-namespace.js';
56
56
  import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
57
+ import type { IAgentEventRecorder } from '../audit/audit.contract.js';
58
+ import { RequestAudit } from '../audit/request-audit.js';
57
59
  import { ManualFailureMemo } from './manual-failure-memo.js';
58
60
  import { DownstreamPool, POOL_KEY_SEPARATOR, type DownstreamPoolOptions, type Lease } from './downstream-pool.js';
59
61
  import { SurfaceLogThrottle } from './surface-log-throttle.js';
@@ -98,6 +100,12 @@ export interface McpCaller {
98
100
  userId: string;
99
101
  /** The connection-key id (per-key metering rides on it), or null for an OAuth/JWT bearer. */
100
102
  tokenId: string | null;
103
+ /**
104
+ * The agent connection behind an OAuth bearer (or the local server's
105
+ * exchanged grant), or null. With `tokenId`, the two ways a call is
106
+ * attributed in the Audit log; a request with neither records nothing.
107
+ */
108
+ connectionId?: string | null;
101
109
  /** The raw bearer the request carried. */
102
110
  bearer: string;
103
111
  }
@@ -268,6 +276,10 @@ export class McpService {
268
276
  // authorization when one of its TOOL sign-ins breaks. Optional: without
269
277
  // it, broken sign-ins surface only as the /connect link in the result.
270
278
  private readonly revokeOAuthAccess?: (bearer: string) => Promise<void>,
279
+ // The Audit log's recorder: every tool call, skill read and meta-tool run
280
+ // of a request that arrived with a key or an agent connection goes
281
+ // through it. Optional like the others; without it nothing is recorded.
282
+ private readonly auditRecorder?: IAgentEventRecorder,
271
283
  ) {
272
284
  this.downstream = new DownstreamPool<PooledDownstream>({
273
285
  ...opts.downstreamPool,
@@ -332,6 +344,7 @@ export class McpService {
332
344
  */
333
345
  async createRequestServer(caller: McpCaller, messages: unknown): Promise<Server> {
334
346
  const { userId, tokenId, bearer } = caller;
347
+ const connectionId = caller.connectionId ?? null;
335
348
  // The loopback surface (`/api/agent/*`) accepts connection keys and
336
349
  // internal tokens only. A connection-key request passes the caller's own
337
350
  // key through (per-key metering rides on it); an OAuth/JWT request's
@@ -347,8 +360,30 @@ export class McpService {
347
360
 
348
361
  let instructions: Promise<ComposedAgentInstructions> | undefined;
349
362
  const agentInstructions = () => (instructions ??= this.composeAgentInstructions());
363
+ // The Audit log's view of this request — only when the request can be
364
+ // attributed to a key or an agent connection. The catalog names it
365
+ // classifies pooled tools by are the surface's, which is built lazily
366
+ // below, so it reads them through a holder the surface fills in.
367
+ const catalogNamesHolder: { current: ReadonlyMap<string, string> } = { current: new Map() };
368
+ const principal = tokenId != null ? { kind: 'key' as const, id: tokenId } : connectionId ? { kind: 'agent' as const, id: connectionId } : null;
369
+ const audit =
370
+ this.auditRecorder && principal
371
+ ? new RequestAudit(this.auditRecorder, userId, principal, {
372
+ kbManualName: EXTERNAL_KB_MANUAL_NAME,
373
+ catalogNames: () => catalogNamesHolder.current,
374
+ loadSkills: () =>
375
+ this.fetchSkillList(loopbackBearer).then((skills) =>
376
+ skills.flatMap((s) => (s.path ? [{ name: s.name, path: s.path }] : [])),
377
+ ),
378
+ })
379
+ : null;
350
380
  let surface: Promise<RequestSurface> | undefined;
351
- const requestSurface = () => (surface ??= this.buildSurface(userId, tokenId, loopbackBearer));
381
+ const requestSurface = () =>
382
+ (surface ??= this.buildSurface(userId, tokenId, loopbackBearer).then((built) => {
383
+ catalogNamesHolder.current = built.catalogNames;
384
+ audit?.instrumentChainCalls(built.client);
385
+ return built;
386
+ }));
352
387
 
353
388
  const initializing = (Array.isArray(messages) ? messages : [messages]).some((m) => isInitializeRequest(m));
354
389
  const server = new Server(
@@ -432,8 +467,12 @@ export class McpService {
432
467
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
433
468
  const { client, tools, unavailable, catalogNames } = await requestSurface();
434
469
  const toolName = request.params.name;
470
+ const args = request.params.arguments ?? {};
435
471
  if (META_TOOL_NAMES.has(toolName)) {
436
- return this.dispatchMetaTool(client, toolName, request.params.arguments ?? {});
472
+ // A meta-tool is the platform's own; its inner tool calls (a chain's)
473
+ // are recorded one by one through the instrumented client.
474
+ const run = () => this.dispatchMetaTool(client, toolName, args);
475
+ return audit ? audit.call(toolName, args, run, isErrorResult) : run();
437
476
  }
438
477
  /**
439
478
  * The needs-authorization answer, when the caller's sign-in for the
@@ -476,11 +515,18 @@ export class McpService {
476
515
  .filter((m) => toolName.startsWith(`${m.utcpName}_`))
477
516
  .sort((a, b) => b.utcpName.length - a.utcpName.length)[0];
478
517
  const answer = owner ? await needsAuthorization(owner.utcpName) : null;
518
+ if (answer && owner && audit) {
519
+ await audit.denied(`${owner.utcpName}.${toolName.slice(owner.utcpName.length + 1)}`, args);
520
+ }
479
521
  return answer ?? toolError(retiredToolMessage(toolName) ?? `Unknown tool "${toolName}".`);
480
522
  }
481
523
  const needsAuth = await needsAuthorization(proxied.manualName);
482
- if (needsAuth) return needsAuth;
483
- return this.dispatch(client, proxied, request, extra);
524
+ if (needsAuth) {
525
+ if (audit) await audit.denied(proxied.utcpName, args);
526
+ return needsAuth;
527
+ }
528
+ const run = () => this.dispatch(client, proxied, request, extra);
529
+ return audit ? audit.call(proxied.utcpName, args, run, isErrorResult) : run();
484
530
  });
485
531
 
486
532
  // Prompts = skills. Each skill becomes a user-callable prompt (slash command
@@ -499,7 +545,14 @@ export class McpService {
499
545
  });
500
546
 
501
547
  server.setRequestHandler(GetPromptRequestSchema, async (request): Promise<GetPromptResult> => {
548
+ const started = performance.now();
502
549
  const skill = await this.fetchSkill(loopbackBearer, request.params.name);
550
+ // A prompt IS a skill read — the same event `get_skill` produces.
551
+ audit?.emit(
552
+ { kind: 'skill', manual: skill?.path ?? null, name: request.params.name },
553
+ skill ? 'ok' : 'error',
554
+ Math.round(performance.now() - started),
555
+ );
503
556
  if (!skill) {
504
557
  throw new McpError(ErrorCode.InvalidParams, `Unknown skill "${request.params.name}".`);
505
558
  }
@@ -1176,6 +1229,11 @@ export class McpService {
1176
1229
  }
1177
1230
  }
1178
1231
 
1232
+ /** Whether a tool result is the tool saying it failed — what the Audit log records as `error`. */
1233
+ function isErrorResult(result: CallToolResult): boolean {
1234
+ return result.isError === true;
1235
+ }
1236
+
1179
1237
  /**
1180
1238
  * The UTCP client config for `userId`: the given variables, plus the per-user
1181
1239
  * `bevel-secrets` loader that resolves every `${SECRET}` ref lazily. Shared by