@vellumai/assistant 0.3.28 → 0.4.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 (199) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/session-agent-loop.ts +5 -5
  157. package/src/daemon/session-lifecycle.ts +25 -17
  158. package/src/daemon/session-memory.ts +2 -2
  159. package/src/daemon/session-process.ts +1 -20
  160. package/src/daemon/session-runtime-assembly.ts +28 -22
  161. package/src/daemon/session-tool-setup.ts +2 -2
  162. package/src/daemon/session.ts +3 -3
  163. package/src/memory/canonical-guardian-store.ts +63 -1
  164. package/src/memory/channel-guardian-store.ts +1 -0
  165. package/src/memory/conversation-crud.ts +7 -7
  166. package/src/memory/db-init.ts +4 -0
  167. package/src/memory/embedding-local.ts +257 -39
  168. package/src/memory/embedding-runtime-manager.ts +471 -0
  169. package/src/memory/guardian-bindings.ts +25 -1
  170. package/src/memory/indexer.ts +3 -3
  171. package/src/memory/ingress-invite-store.ts +45 -0
  172. package/src/memory/job-handlers/backfill.ts +16 -9
  173. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  174. package/src/memory/migrations/index.ts +1 -0
  175. package/src/memory/qdrant-client.ts +31 -22
  176. package/src/memory/schema.ts +4 -0
  177. package/src/notifications/copy-composer.ts +15 -0
  178. package/src/runtime/access-request-helper.ts +43 -7
  179. package/src/runtime/actor-trust-resolver.ts +46 -50
  180. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  181. package/src/runtime/channel-retry-sweep.ts +18 -6
  182. package/src/runtime/guardian-context-resolver.ts +38 -96
  183. package/src/runtime/guardian-reply-router.ts +31 -1
  184. package/src/runtime/ingress-service.ts +80 -3
  185. package/src/runtime/invite-redemption-service.ts +141 -2
  186. package/src/runtime/routes/channel-route-shared.ts +1 -1
  187. package/src/runtime/routes/channel-routes.ts +1 -1
  188. package/src/runtime/routes/conversation-routes.ts +2 -2
  189. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  190. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  191. package/src/runtime/routes/ingress-routes.ts +52 -4
  192. package/src/runtime/routes/pairing-routes.ts +3 -0
  193. package/src/tools/guardian-control-plane-policy.ts +2 -2
  194. package/src/tools/tool-approval-handler.ts +11 -11
  195. package/src/tools/types.ts +2 -2
  196. package/src/util/logger.ts +20 -8
  197. package/src/util/platform.ts +10 -0
  198. package/src/util/voice-code.ts +29 -0
  199. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -470,7 +470,7 @@ describe('credential_store tool — oauth2_connect error paths', () => {
470
470
  client_id: 'test-client-id',
471
471
  }, { ..._ctx, isInteractive: false });
472
472
  expect(result.isError).toBe(true);
473
- expect(result.content).toContain('interactive client session');
473
+ expect(result.content).toContain('non-interactive session');
474
474
  });
475
475
 
476
476
  test('resolves gmail alias to integration:gmail', async () => {
@@ -672,7 +672,7 @@ describe('credential_store tool — tool definition', () => {
672
672
  expect(schema.required).toContain('action');
673
673
  const props = schema.properties as Record<string, Record<string, unknown>>;
674
674
  expect(props.action.enum).toEqual(
675
- ['store', 'list', 'delete', 'prompt', 'oauth2_connect'],
675
+ ['store', 'list', 'delete', 'prompt', 'oauth2_connect', 'describe'],
676
676
  );
677
677
  });
678
678
 
@@ -406,18 +406,19 @@ describe('credential_store tool', () => {
406
406
  expect(entries[0].service).toBe('svc-b');
407
407
  });
408
408
 
409
- test('returns error when secure storage is corrupt/unreadable', async () => {
409
+ test('recovers from corrupt secure storage by resetting and returning empty list', async () => {
410
410
  // Store a credential so metadata exists
411
411
  await credentialStoreTool.execute({
412
412
  action: 'store', service: 'svc-x', field: 'key', value: 'val-x',
413
413
  }, _ctx);
414
414
 
415
- // Corrupt the encrypted store file so listKeys() throws
415
+ // Corrupt the encrypted store file the store auto-recovers by
416
+ // backing up the corrupt file and creating a fresh store
416
417
  writeFileSync(STORE_PATH, 'not-valid-json!!!', 'utf-8');
417
418
 
418
419
  const result = await credentialStoreTool.execute({ action: 'list' }, _ctx);
419
- expect(result.isError).toBe(true);
420
- expect(result.content).toContain('failed to read secure storage');
420
+ // Store auto-recovers: list succeeds but the corrupted credentials are lost
421
+ expect(result.isError).toBe(false);
421
422
  });
422
423
  });
423
424
 
@@ -94,6 +94,15 @@ const conversation = {
94
94
  };
95
95
 
96
96
  mock.module('../memory/conversation-store.js', () => ({
97
+ setConversationOriginChannelIfUnset: () => {},
98
+ updateConversationContextWindow: () => {},
99
+ deleteMessageById: () => {},
100
+ updateConversationTitle: () => {},
101
+ updateConversationUsage: () => {},
102
+ addMessage: () => ({ id: 'mock-msg-id' }),
103
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
104
+ getConversationOriginInterface: () => null,
105
+ getConversationOriginChannel: () => null,
97
106
  getLatestConversation: () => conversation,
98
107
  createConversation: () => conversation,
99
108
  getConversation: (id: string) => (id === conversation.id ? conversation : null),
@@ -203,6 +203,8 @@ mock.module('../providers/ratelimit.js', () => ({
203
203
 
204
204
  mock.module('../config/loader.js', () => ({
205
205
  getConfig: () => ({
206
+ ui: {},
207
+
206
208
  provider: 'mock-provider',
207
209
  providerOrder: ['mock-provider'],
208
210
  maxTokens: 4096,
@@ -243,7 +245,31 @@ mock.module('../memory/external-conversation-store.js', () => ({
243
245
  getBindingsForConversations: () => new Map(),
244
246
  }));
245
247
 
248
+ mock.module('../memory/conversation-attention-store.js', () => ({
249
+ getAttentionStateByConversationIds: () => new Map(),
250
+ recordAttentionSignal: () => {},
251
+ recordConversationSeenSignal: () => {},
252
+ }));
253
+
254
+ mock.module('../memory/canonical-guardian-store.js', () => ({
255
+ generateCanonicalRequestCode: () => 'mock-code-0000',
256
+ createCanonicalGuardianRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
257
+ submitCanonicalRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
258
+ getCanonicalRequest: () => null,
259
+ resolveCanonicalRequest: () => false,
260
+ listPendingCanonicalRequests: () => [],
261
+ }));
262
+
246
263
  mock.module('../memory/conversation-store.js', () => ({
264
+ setConversationOriginChannelIfUnset: () => {},
265
+ updateConversationContextWindow: () => {},
266
+ deleteMessageById: () => {},
267
+ updateConversationTitle: () => {},
268
+ updateConversationUsage: () => {},
269
+ addMessage: () => ({ id: 'mock-msg-id' }),
270
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
271
+ getConversationOriginInterface: () => null,
272
+ getConversationOriginChannel: () => null,
247
273
  getLatestConversation: () => conversation,
248
274
  createConversation: (titleOrOpts?: string | { title?: string; threadType?: string }) => {
249
275
  lastCreateConversationArgs = titleOrOpts;
@@ -266,6 +292,7 @@ mock.module('../memory/conversation-store.js', () => ({
266
292
  getMessages: () => [],
267
293
  listConversations: () => [conversation],
268
294
  countConversations: () => 1,
295
+ getDisplayMetaForConversations: () => new Map(),
269
296
  }));
270
297
 
271
298
  mock.module('../daemon/session.js', () => ({
@@ -27,6 +27,8 @@ let mockVoiceConfig = {
27
27
 
28
28
  mock.module('../config/loader.js', () => ({
29
29
  getConfig: () => ({
30
+ ui: {},
31
+
30
32
  calls: { voice: mockVoiceConfig },
31
33
  }),
32
34
  }));
@@ -253,24 +253,29 @@ describe('encrypted-store', () => {
253
253
  expect(getKey('test')).toBe('value');
254
254
  });
255
255
 
256
- test('setKey refuses to overwrite a corrupt store file', () => {
256
+ test('setKey recovers from a corrupt store file by backing up and creating fresh store', () => {
257
257
  // Write a valid store first
258
258
  setKey('existing', 'old-secret');
259
259
  // Corrupt the store
260
260
  writeFileSync(STORE_PATH, 'corrupted data');
261
- // setKey should fail rather than overwrite with new salt
261
+ // setKey should recover by backing up corrupt file and creating fresh store
262
262
  const result = setKey('new-key', 'new-value');
263
- expect(result).toBe(false);
263
+ expect(result).toBe(true);
264
+ // Old key is lost but new key works
265
+ expect(getKey('new-key')).toBe('new-value');
266
+ expect(getKey('existing')).toBeUndefined();
264
267
  });
265
268
 
266
- test('setKey refuses to overwrite a store with invalid version', () => {
269
+ test('setKey recovers from a store with invalid version', () => {
267
270
  writeFileSync(STORE_PATH, JSON.stringify({
268
271
  version: 99,
269
272
  salt: 'abc',
270
273
  entries: {},
271
274
  }));
275
+ // setKey should recover by backing up invalid store and creating fresh store
272
276
  const result = setKey('test', 'value');
273
- expect(result).toBe(false);
277
+ expect(result).toBe(true);
278
+ expect(getKey('test')).toBe('value');
274
279
  });
275
280
 
276
281
  test('writeStore enforces 0600 permissions on existing files', () => {
@@ -27,7 +27,9 @@ mock.module('../util/logger.js', () => ({
27
27
  }));
28
28
 
29
29
  mock.module('../config/loader.js', () => ({
30
- getConfig: () => ({ memory: {} }),
30
+ getConfig: () => ({
31
+ ui: {},
32
+ memory: {} }),
31
33
  }));
32
34
 
33
35
  import type { Database } from 'bun:sqlite';
@@ -243,9 +243,9 @@ describe('gateway-only ingress enforcement', () => {
243
243
  body: makeFormBody({ CallSid: 'CA123', AccountSid: 'AC_test' }),
244
244
  });
245
245
  expect(res.status).toBe(410);
246
- const body = await res.json() as { error: string; code: string };
247
- expect(body.code).toBe('GATEWAY_ONLY');
248
- expect(body.error).toContain('Direct webhook access disabled');
246
+ const body = await res.json() as { error: { code: string; message: string } };
247
+ expect(body.error.code).toBe('GONE');
248
+ expect(body.error.message).toContain('Direct webhook access disabled');
249
249
  });
250
250
 
251
251
  test('POST /webhooks/twilio/status returns 410', async () => {
@@ -255,8 +255,8 @@ describe('gateway-only ingress enforcement', () => {
255
255
  body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
256
256
  });
257
257
  expect(res.status).toBe(410);
258
- const body = await res.json() as { error: string; code: string };
259
- expect(body.code).toBe('GATEWAY_ONLY');
258
+ const body = await res.json() as { error: { code: string; message: string } };
259
+ expect(body.error.code).toBe('GONE');
260
260
  });
261
261
 
262
262
  test('POST /webhooks/twilio/connect-action returns 410', async () => {
@@ -266,8 +266,8 @@ describe('gateway-only ingress enforcement', () => {
266
266
  body: makeFormBody({ CallSid: 'CA123' }),
267
267
  });
268
268
  expect(res.status).toBe(410);
269
- const body = await res.json() as { error: string; code: string };
270
- expect(body.code).toBe('GATEWAY_ONLY');
269
+ const body = await res.json() as { error: { code: string; message: string } };
270
+ expect(body.error.code).toBe('GONE');
271
271
  });
272
272
 
273
273
  test('POST /v1/calls/twilio/voice-webhook returns 410', async () => {
@@ -277,8 +277,8 @@ describe('gateway-only ingress enforcement', () => {
277
277
  body: makeFormBody({ CallSid: 'CA123' }),
278
278
  });
279
279
  expect(res.status).toBe(410);
280
- const body = await res.json() as { error: string; code: string };
281
- expect(body.code).toBe('GATEWAY_ONLY');
280
+ const body = await res.json() as { error: { code: string; message: string } };
281
+ expect(body.error.code).toBe('GONE');
282
282
  });
283
283
 
284
284
  test('POST /v1/calls/twilio/status returns 410', async () => {
@@ -288,8 +288,8 @@ describe('gateway-only ingress enforcement', () => {
288
288
  body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
289
289
  });
290
290
  expect(res.status).toBe(410);
291
- const body = await res.json() as { error: string; code: string };
292
- expect(body.code).toBe('GATEWAY_ONLY');
291
+ const body = await res.json() as { error: { code: string; message: string } };
292
+ expect(body.error.code).toBe('GONE');
293
293
  });
294
294
  });
295
295
 
@@ -304,9 +304,9 @@ describe('gateway-only ingress enforcement', () => {
304
304
  body: makeFormBody({ Body: 'hello', From: '+15551234567', To: '+15559876543', MessageSid: 'SM123' }),
305
305
  });
306
306
  expect(res.status).toBe(410);
307
- const body = await res.json() as { error: string; code: string };
308
- expect(body.code).toBe('GATEWAY_ONLY');
309
- expect(body.error).toContain('Direct webhook access disabled');
307
+ const body = await res.json() as { error: { code: string; message: string } };
308
+ expect(body.error.code).toBe('GONE');
309
+ expect(body.error.message).toContain('Direct webhook access disabled');
310
310
  });
311
311
 
312
312
  test('POST /v1/calls/twilio/sms returns 410 (legacy path also blocked)', async () => {
@@ -316,8 +316,8 @@ describe('gateway-only ingress enforcement', () => {
316
316
  body: makeFormBody({ Body: 'hello', From: '+15551234567', MessageSid: 'SM456' }),
317
317
  });
318
318
  expect(res.status).toBe(410);
319
- const body = await res.json() as { error: string; code: string };
320
- expect(body.code).toBe('GATEWAY_ONLY');
319
+ const body = await res.json() as { error: { code: string; message: string } };
320
+ expect(body.error.code).toBe('GONE');
321
321
  });
322
322
 
323
323
  test('POST /webhooks/twilio/sms with valid auth still returns 410 (auth does not bypass gateway-only)', async () => {
@@ -331,8 +331,8 @@ describe('gateway-only ingress enforcement', () => {
331
331
  });
332
332
  // The gateway-only guard runs before auth for Twilio webhook paths
333
333
  expect(res.status).toBe(410);
334
- const body = await res.json() as { error: string; code: string };
335
- expect(body.code).toBe('GATEWAY_ONLY');
334
+ const body = await res.json() as { error: { code: string; message: string } };
335
+ expect(body.error.code).toBe('GONE');
336
336
  });
337
337
  });
338
338
 
@@ -407,9 +407,9 @@ describe('gateway-only ingress enforcement', () => {
407
407
  },
408
408
  });
409
409
  expect(res.status).toBe(403);
410
- const body = await res.json() as { error: string; code: string };
411
- expect(body.code).toBe('GATEWAY_ONLY');
412
- expect(body.error).toContain('Direct relay access disabled');
410
+ const body = await res.json() as { error: { code: string; message: string } };
411
+ expect(body.error.code).toBe('FORBIDDEN');
412
+ expect(body.error.message).toContain('Direct relay access disabled');
413
413
  });
414
414
 
415
415
  test('allows request with no origin header (private network peer)', async () => {
@@ -41,7 +41,6 @@ describe('Messaging tool contract', () => {
41
41
  'messaging_read',
42
42
  'messaging_search',
43
43
  'messaging_send',
44
- 'send_notification',
45
44
  'messaging_reply',
46
45
  'messaging_mark_read',
47
46
  'messaging_analyze_activity',
@@ -380,7 +380,7 @@ describe('enforceGuardianOnlyPolicy', () => {
380
380
  test('non-guardian actor denied for guardian endpoint', () => {
381
381
  const result = enforceGuardianOnlyPolicy('bash', {
382
382
  command: 'curl http://localhost:3000/v1/integrations/guardian/outbound/start',
383
- }, 'non-guardian');
383
+ }, 'trusted_contact');
384
384
  expect(result.denied).toBe(true);
385
385
  expect(result.reason).toContain('restricted to guardian users');
386
386
  });
@@ -388,7 +388,7 @@ describe('enforceGuardianOnlyPolicy', () => {
388
388
  test('unverified_channel actor denied for guardian endpoint', () => {
389
389
  const result = enforceGuardianOnlyPolicy('network_request', {
390
390
  url: 'https://api.example.com/v1/integrations/guardian/challenge',
391
- }, 'unverified_channel');
391
+ }, 'unknown');
392
392
  expect(result.denied).toBe(true);
393
393
  expect(result.reason).toContain('restricted to guardian users');
394
394
  });
@@ -419,14 +419,14 @@ describe('enforceGuardianOnlyPolicy', () => {
419
419
  test('non-guardian actor is NOT denied for unrelated endpoint', () => {
420
420
  const result = enforceGuardianOnlyPolicy('bash', {
421
421
  command: 'curl http://localhost:3000/v1/messages',
422
- }, 'non-guardian');
422
+ }, 'trusted_contact');
423
423
  expect(result.denied).toBe(false);
424
424
  });
425
425
 
426
426
  test('non-guardian actor is NOT denied for unrelated tool', () => {
427
427
  const result = enforceGuardianOnlyPolicy('file_read', {
428
428
  path: 'README.md',
429
- }, 'non-guardian');
429
+ }, 'trusted_contact');
430
430
  expect(result.denied).toBe(false);
431
431
  });
432
432
  });
@@ -445,7 +445,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
445
445
  const result = await executor.execute(
446
446
  'bash',
447
447
  { command: 'curl -X POST http://localhost:3000/v1/integrations/guardian/outbound/start' },
448
- makeContext({ guardianActorRole: 'non-guardian' }),
448
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
449
449
  );
450
450
  expect(result.isError).toBe(true);
451
451
  expect(result.content).toContain('restricted to guardian users');
@@ -456,7 +456,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
456
456
  const result = await executor.execute(
457
457
  'network_request',
458
458
  { url: 'https://api.example.com/v1/integrations/guardian/challenge' },
459
- makeContext({ guardianActorRole: 'unverified_channel' }),
459
+ makeContext({ guardianTrustClass: 'unknown' }),
460
460
  );
461
461
  expect(result.isError).toBe(true);
462
462
  expect(result.content).toContain('restricted to guardian users');
@@ -467,18 +467,18 @@ describe('ToolExecutor guardian-only policy gate', () => {
467
467
  const result = await executor.execute(
468
468
  'bash',
469
469
  { command: 'curl -X POST http://localhost:3000/v1/integrations/guardian/outbound/start' },
470
- makeContext({ guardianActorRole: 'guardian' }),
470
+ makeContext({ guardianTrustClass: 'guardian' }),
471
471
  );
472
472
  expect(result.isError).toBe(false);
473
473
  expect(result.content).toBe('ok');
474
474
  });
475
475
 
476
- test('undefined guardianActorRole is NOT blocked from guardian endpoint', async () => {
476
+ test('undefined guardianTrustClass is NOT blocked from guardian endpoint', async () => {
477
477
  const executor = new ToolExecutor(makePrompter());
478
478
  const result = await executor.execute(
479
479
  'bash',
480
480
  { command: 'curl http://localhost:3000/v1/integrations/guardian/status' },
481
- makeContext(), // no guardianActorRole set
481
+ makeContext(), // no guardianTrustClass set
482
482
  );
483
483
  expect(result.isError).toBe(false);
484
484
  expect(result.content).toBe('ok');
@@ -489,7 +489,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
489
489
  const result = await executor.execute(
490
490
  'bash',
491
491
  { command: 'curl http://localhost:3000/v1/messages' },
492
- makeContext({ guardianActorRole: 'non-guardian' }),
492
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
493
493
  );
494
494
  expect(result.isError).toBe(true);
495
495
  expect(result.content).toContain('requires guardian approval');
@@ -500,7 +500,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
500
500
  const result = await executor.execute(
501
501
  'file_read',
502
502
  { path: 'README.md' },
503
- makeContext({ guardianActorRole: 'non-guardian' }),
503
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
504
504
  );
505
505
  expect(result.isError).toBe(false);
506
506
  expect(result.content).toBe('ok');
@@ -513,7 +513,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
513
513
  'bash',
514
514
  { command: 'curl http://localhost:3000/v1/integrations/guardian/outbound/cancel' },
515
515
  makeContext({
516
- guardianActorRole: 'non-guardian',
516
+ guardianTrustClass: 'trusted_contact',
517
517
  onToolLifecycleEvent: (event: ToolLifecycleEvent) => {
518
518
  if (event.type === 'permission_denied') {
519
519
  capturedEvent = event as ToolPermissionDeniedEvent;
@@ -531,7 +531,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
531
531
  const result = await executor.execute(
532
532
  'web_fetch',
533
533
  { url: 'http://localhost:3000/v1/integrations/guardian/outbound/resend' },
534
- makeContext({ guardianActorRole: 'non-guardian' }),
534
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
535
535
  );
536
536
  expect(result.isError).toBe(true);
537
537
  expect(result.content).toContain('restricted to guardian users');
@@ -542,7 +542,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
542
542
  const result = await executor.execute(
543
543
  'browser_navigate',
544
544
  { url: 'http://localhost:3000/v1/integrations/guardian/status' },
545
- makeContext({ guardianActorRole: 'non-guardian' }),
545
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
546
546
  );
547
547
  expect(result.isError).toBe(true);
548
548
  expect(result.content).toContain('restricted to guardian users');
@@ -553,7 +553,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
553
553
  const result = await executor.execute(
554
554
  'host_bash',
555
555
  { command: 'curl -X POST https://internal:8080/v1/integrations/guardian/challenge' },
556
- makeContext({ guardianActorRole: 'non-guardian' }),
556
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
557
557
  );
558
558
  expect(result.isError).toBe(true);
559
559
  expect(result.content).toContain('restricted to guardian users');
@@ -573,7 +573,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
573
573
  const result = await executor.execute(
574
574
  'network_request',
575
575
  { url: `https://api.example.com${path}` },
576
- makeContext({ guardianActorRole: 'non-guardian' }),
576
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
577
577
  );
578
578
  expect(result.isError).toBe(true);
579
579
  expect(result.content).toContain('restricted to guardian users');
@@ -585,7 +585,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
585
585
  const result = await executor.execute(
586
586
  'host_file_read',
587
587
  { path: '/Users/noaflaherty/.ssh/config' },
588
- makeContext({ guardianActorRole: 'non-guardian' }),
588
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
589
589
  );
590
590
  expect(result.isError).toBe(true);
591
591
  expect(result.content).toContain('requires guardian approval');
@@ -596,7 +596,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
596
596
  const result = await executor.execute(
597
597
  'reminder_create',
598
598
  { fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
599
- makeContext({ guardianActorRole: 'unverified_channel' }),
599
+ makeContext({ guardianTrustClass: 'unknown' }),
600
600
  );
601
601
  expect(result.isError).toBe(true);
602
602
  expect(result.content).toContain('verified channel identity');
@@ -607,7 +607,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
607
607
  const result = await executor.execute(
608
608
  'reminder_create',
609
609
  { fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
610
- makeContext({ guardianActorRole: 'guardian' }),
610
+ makeContext({ guardianTrustClass: 'guardian' }),
611
611
  );
612
612
  expect(result.isError).toBe(false);
613
613
  expect(result.content).toBe('ok');
@@ -42,6 +42,8 @@ mock.module('../memory/channel-guardian-store.js', () => ({
42
42
 
43
43
  mock.module('../config/loader.js', () => ({
44
44
  getConfig: () => ({
45
+ ui: {},
46
+
45
47
  calls: {
46
48
  userConsultTimeoutSeconds: 120,
47
49
  },
@@ -137,7 +137,7 @@ function registerPendingInteraction(
137
137
 
138
138
  function makeGuardianContext(): GuardianContext {
139
139
  return {
140
- actorRole: 'guardian',
140
+ trustClass: 'guardian',
141
141
  denialReason: undefined,
142
142
  };
143
143
  }
@@ -530,3 +530,70 @@ describe('guardian grant minting on tool-approval decisions', () => {
530
530
  composeSpy.mockRestore();
531
531
  });
532
532
  });
533
+
534
+ describe('approval interception trust-class regression coverage', () => {
535
+ let deliverSpy: ReturnType<typeof spyOn>;
536
+ let composeSpy: ReturnType<typeof spyOn>;
537
+
538
+ beforeEach(() => {
539
+ resetTables();
540
+ deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
541
+ composeSpy = spyOn(approvalMessageComposer, 'composeApprovalMessageGenerative')
542
+ .mockResolvedValue('test message');
543
+ });
544
+
545
+ test('identity-known unknown sender does not auto-deny pending approval', async () => {
546
+ const requestId = 'req-unknown-no-auto-deny-1';
547
+ const sessionMock = registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
548
+ createTestGuardianApproval(requestId);
549
+
550
+ const result = await handleApprovalInterception({
551
+ conversationId: CONVERSATION_ID,
552
+ content: 'approve',
553
+ externalChatId: REQUESTER_CHAT,
554
+ sourceChannel: 'telegram',
555
+ senderExternalUserId: 'intruder-user-1',
556
+ replyCallbackUrl: 'https://gateway.test/deliver',
557
+ guardianCtx: {
558
+ trustClass: 'unknown',
559
+ },
560
+ assistantId: ASSISTANT_ID,
561
+ });
562
+
563
+ expect(result.handled).toBe(true);
564
+ expect(result.type).toBe('assistant_turn');
565
+ expect(sessionMock).not.toHaveBeenCalled();
566
+
567
+ deliverSpy.mockRestore();
568
+ composeSpy.mockRestore();
569
+ });
570
+
571
+ test('legacy unverified sender still auto-denies pending approval', async () => {
572
+ const requestId = 'req-unknown-auto-deny-1';
573
+ const sessionMock = registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
574
+ createTestGuardianApproval(requestId);
575
+
576
+ const result = await handleApprovalInterception({
577
+ conversationId: CONVERSATION_ID,
578
+ content: 'approve',
579
+ externalChatId: REQUESTER_CHAT,
580
+ sourceChannel: 'telegram',
581
+ senderExternalUserId: undefined,
582
+ replyCallbackUrl: 'https://gateway.test/deliver',
583
+ guardianCtx: {
584
+ trustClass: 'unknown',
585
+ denialReason: 'no_identity',
586
+ },
587
+ assistantId: ASSISTANT_ID,
588
+ });
589
+
590
+ expect(result.handled).toBe(true);
591
+ expect(result.type).toBe('decision_applied');
592
+ expect(sessionMock).toHaveBeenCalled();
593
+ expect(sessionMock.mock.calls[0]?.[0]).toBe(requestId);
594
+ expect(sessionMock.mock.calls[0]?.[1]).toBe('deny');
595
+
596
+ deliverSpy.mockRestore();
597
+ composeSpy.mockRestore();
598
+ });
599
+ });
@@ -364,15 +364,16 @@ describe('HTTP route: handleStartOutbound', () => {
364
364
  const req = jsonRequest({ destination: '+15551234567' });
365
365
  const resp = await handleStartOutbound(req);
366
366
  expect(resp.status).toBe(400);
367
- const body = await resp.json() as Record<string, unknown>;
368
- expect(body.error).toBe('missing_channel');
367
+ const body = await resp.json() as { error: { message: string; code: string } };
368
+ expect(body.error.code).toBe('BAD_REQUEST');
369
+ expect(body.error.message).toContain('channel');
369
370
  });
370
371
 
371
372
  test('returns 400 for missing destination (SMS)', async () => {
372
373
  const req = jsonRequest({ channel: 'sms' });
373
374
  const resp = await handleStartOutbound(req);
374
375
  expect(resp.status).toBe(400);
375
- const body = await resp.json() as Record<string, unknown>;
376
+ const body = await resp.json() as { error?: string };
376
377
  expect(body.error).toBe('missing_destination');
377
378
  });
378
379
 
@@ -391,15 +392,16 @@ describe('HTTP route: handleResendOutbound', () => {
391
392
  const req = jsonRequest({});
392
393
  const resp = await handleResendOutbound(req);
393
394
  expect(resp.status).toBe(400);
394
- const body = await resp.json() as Record<string, unknown>;
395
- expect(body.error).toBe('missing_channel');
395
+ const body = await resp.json() as { error: { message: string; code: string } };
396
+ expect(body.error.code).toBe('BAD_REQUEST');
397
+ expect(body.error.message).toContain('channel');
396
398
  });
397
399
 
398
400
  test('returns 400 for no_active_session', async () => {
399
401
  const req = jsonRequest({ channel: 'sms', assistantId: 'resend-no-session' });
400
402
  const resp = await handleResendOutbound(req);
401
403
  expect(resp.status).toBe(400);
402
- const body = await resp.json() as Record<string, unknown>;
404
+ const body = await resp.json() as { error?: string };
403
405
  expect(body.error).toBe('no_active_session');
404
406
  });
405
407
 
@@ -432,15 +434,16 @@ describe('HTTP route: handleCancelOutbound', () => {
432
434
  const req = jsonRequest({});
433
435
  const resp = await handleCancelOutbound(req);
434
436
  expect(resp.status).toBe(400);
435
- const body = await resp.json() as Record<string, unknown>;
436
- expect(body.error).toBe('missing_channel');
437
+ const body = await resp.json() as { error: { message: string; code: string } };
438
+ expect(body.error.code).toBe('BAD_REQUEST');
439
+ expect(body.error.message).toContain('channel');
437
440
  });
438
441
 
439
442
  test('returns 400 for no_active_session', async () => {
440
443
  const req = jsonRequest({ channel: 'sms', assistantId: 'cancel-no-session' });
441
444
  const resp = await handleCancelOutbound(req);
442
445
  expect(resp.status).toBe(400);
443
- const body = await resp.json() as Record<string, unknown>;
446
+ const body = await resp.json() as { error?: string };
444
447
  expect(body.error).toBe('no_active_session');
445
448
  });
446
449