@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -67,6 +67,11 @@ mock.module('../memory/ingress-member-store.js', () => ({
67
67
  updateLastSeen: () => {},
68
68
  }));
69
69
  import type { Session } from '../daemon/session.js';
70
+ import {
71
+ createCanonicalGuardianDelivery,
72
+ createCanonicalGuardianRequest,
73
+ getCanonicalGuardianRequest,
74
+ } from '../memory/canonical-guardian-store.js';
70
75
  import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
71
76
  import {
72
77
  createApprovalRequest,
@@ -110,6 +115,9 @@ function ensureConversation(conversationId: string): void {
110
115
 
111
116
  function resetTables(): void {
112
117
  const db = getDb();
118
+ db.run('DELETE FROM scoped_approval_grants');
119
+ db.run('DELETE FROM canonical_guardian_deliveries');
120
+ db.run('DELETE FROM canonical_guardian_requests');
113
121
  db.run('DELETE FROM channel_guardian_approval_requests');
114
122
  db.run('DELETE FROM channel_guardian_verification_challenges');
115
123
  db.run('DELETE FROM channel_guardian_bindings');
@@ -458,7 +466,7 @@ describe('empty content with callbackData bypasses validation', () => {
458
466
  const res = await handleChannelInbound(req, noopProcessMessage);
459
467
  expect(res.status).toBe(400);
460
468
  const body = await res.json() as Record<string, unknown>;
461
- expect(body.error).toBe('content or attachmentIds is required');
469
+ expect((body.error as Record<string, unknown>).message).toBe('content or attachmentIds is required');
462
470
  });
463
471
 
464
472
  test('allows empty content when callbackData is present', async () => {
@@ -2645,4 +2653,169 @@ describe('background channel processing approval prompts', () => {
2645
2653
  expect(processCalls.length).toBeGreaterThan(0);
2646
2654
  expect(processCalls[0].options?.isInteractive).toBe(false);
2647
2655
  });
2656
+
2657
+ test('unverified channel turns never broadcast approval prompts', async () => {
2658
+ // No guardian binding is created, so the sender resolves to unverified_channel.
2659
+ const deliverPromptSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2660
+ const processCalls: Array<{ options?: Record<string, unknown> }> = [];
2661
+
2662
+ const processMessage = mock(async (
2663
+ conversationId: string,
2664
+ _content: string,
2665
+ _attachmentIds?: string[],
2666
+ options?: Record<string, unknown>,
2667
+ ) => {
2668
+ processCalls.push({ options });
2669
+
2670
+ // Simulate a pending confirmation becoming visible while background
2671
+ // processing is running. Unverified actors must still not receive it.
2672
+ registerPendingInteraction('req-bg-unverified-1', conversationId, 'host_bash', {
2673
+ input: { command: 'ls -la' },
2674
+ riskLevel: 'medium',
2675
+ });
2676
+
2677
+ await new Promise((resolve) => setTimeout(resolve, 350));
2678
+ return { messageId: 'msg-bg-unverified-1' };
2679
+ });
2680
+
2681
+ const req = makeInboundRequest({
2682
+ content: 'run ls',
2683
+ sourceChannel: 'telegram',
2684
+ replyCallbackUrl: 'https://gateway.test/deliver/telegram',
2685
+ externalMessageId: 'msg-bg-unverified-1',
2686
+ });
2687
+
2688
+ const res = await handleChannelInbound(req, processMessage as unknown as typeof noopProcessMessage, 'token');
2689
+ const body = await res.json() as Record<string, unknown>;
2690
+ expect(body.accepted).toBe(true);
2691
+
2692
+ await new Promise((resolve) => setTimeout(resolve, 700));
2693
+
2694
+ expect(processCalls.length).toBeGreaterThan(0);
2695
+ expect(processCalls[0].options?.isInteractive).toBe(false);
2696
+ expect(deliverPromptSpy).not.toHaveBeenCalled();
2697
+
2698
+ deliverPromptSpy.mockRestore();
2699
+ });
2700
+ });
2701
+
2702
+ // ═══════════════════════════════════════════════════════════════════════════
2703
+ // NL approval routing via destination-scoped canonical requests
2704
+ // ═══════════════════════════════════════════════════════════════════════════
2705
+
2706
+ describe('NL approval routing via destination-scoped canonical requests', () => {
2707
+ beforeEach(() => {
2708
+ resetTables();
2709
+ noopProcessMessage.mockClear();
2710
+ });
2711
+
2712
+ test('guardian plain-text "yes" resolves a pending_question with no guardianExternalUserId via delivery-scoped hint', async () => {
2713
+ // Simulate a voice-originated pending_question without guardianExternalUserId
2714
+ const guardianChatId = 'guardian-chat-nl-1';
2715
+ const guardianUserId = 'guardian-user-nl-1';
2716
+
2717
+ // Ensure the conversation exists so the resolver finds it
2718
+ ensureConversation('conv-voice-nl-1');
2719
+
2720
+ // Create guardian binding for Telegram
2721
+ createBinding({
2722
+ assistantId: 'self',
2723
+ channel: 'telegram',
2724
+ guardianExternalUserId: guardianUserId,
2725
+ guardianDeliveryChatId: guardianChatId,
2726
+ });
2727
+
2728
+ // Create canonical tool_approval request WITHOUT guardianExternalUserId
2729
+ // but WITH a conversationId (required by the tool_approval resolver)
2730
+ const canonicalReq = createCanonicalGuardianRequest({
2731
+ kind: 'tool_approval',
2732
+ sourceType: 'voice',
2733
+ sourceChannel: 'twilio',
2734
+ conversationId: 'conv-voice-nl-1',
2735
+ toolName: 'shell',
2736
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
2737
+ // guardianExternalUserId intentionally omitted
2738
+ });
2739
+
2740
+ // Register pending interaction so resolver can find it
2741
+ registerPendingInteraction(canonicalReq.id, 'conv-voice-nl-1', 'shell');
2742
+
2743
+ // Create canonical delivery row targeting guardian chat
2744
+ createCanonicalGuardianDelivery({
2745
+ requestId: canonicalReq.id,
2746
+ destinationChannel: 'telegram',
2747
+ destinationChatId: guardianChatId,
2748
+ });
2749
+
2750
+ // Send inbound guardian text reply "yes" from that chat
2751
+ const req = makeInboundRequest({
2752
+ sourceChannel: 'telegram',
2753
+ externalChatId: guardianChatId,
2754
+ senderExternalUserId: guardianUserId,
2755
+ content: 'yes',
2756
+ externalMessageId: `msg-nl-approve-${Date.now()}`,
2757
+ });
2758
+ const res = await handleChannelInbound(req, noopProcessMessage as any, TEST_BEARER_TOKEN);
2759
+ const body = await res.json() as Record<string, unknown>;
2760
+
2761
+ expect(body.accepted).toBe(true);
2762
+ expect(body.canonicalRouter).toBe('canonical_decision_applied');
2763
+
2764
+ // Verify the request was resolved
2765
+ const resolved = getCanonicalGuardianRequest(canonicalReq.id);
2766
+ expect(resolved).not.toBeNull();
2767
+ expect(resolved!.status).toBe('approved');
2768
+ });
2769
+
2770
+ test('inbound from different chat ID does not auto-match delivery-scoped canonical request', async () => {
2771
+ const guardianChatId = 'guardian-chat-nl-2';
2772
+ const guardianUserId = 'guardian-user-nl-2';
2773
+ const differentChatId = 'different-chat-999';
2774
+
2775
+ // Create guardian binding for the guardian user on the different chat
2776
+ createBinding({
2777
+ assistantId: 'self',
2778
+ channel: 'telegram',
2779
+ guardianExternalUserId: guardianUserId,
2780
+ guardianDeliveryChatId: differentChatId,
2781
+ });
2782
+
2783
+ // Create canonical pending_question WITHOUT guardianExternalUserId
2784
+ const canonicalReq = createCanonicalGuardianRequest({
2785
+ kind: 'tool_approval',
2786
+ sourceType: 'voice',
2787
+ sourceChannel: 'twilio',
2788
+ toolName: 'shell',
2789
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
2790
+ });
2791
+
2792
+ // Delivery targets the original guardian chat, NOT the different chat
2793
+ createCanonicalGuardianDelivery({
2794
+ requestId: canonicalReq.id,
2795
+ destinationChannel: 'telegram',
2796
+ destinationChatId: guardianChatId,
2797
+ });
2798
+
2799
+ // Send from differentChatId — delivery-scoped lookup should not match
2800
+ const req = makeInboundRequest({
2801
+ sourceChannel: 'telegram',
2802
+ externalChatId: differentChatId,
2803
+ senderExternalUserId: guardianUserId,
2804
+ content: 'approve',
2805
+ externalMessageId: `msg-nl-mismatch-${Date.now()}`,
2806
+ });
2807
+ const res = await handleChannelInbound(req, noopProcessMessage as any, TEST_BEARER_TOKEN);
2808
+ const body = await res.json() as Record<string, unknown>;
2809
+
2810
+ expect(body.accepted).toBe(true);
2811
+ // Should NOT have been consumed by canonical router since there are no
2812
+ // delivery-scoped pending requests for this chat, and identity-based
2813
+ // fallback finds no match either (no guardianExternalUserId on request)
2814
+ expect(body.canonicalRouter).toBeUndefined();
2815
+
2816
+ // Request should remain pending
2817
+ const unchanged = getCanonicalGuardianRequest(canonicalReq.id);
2818
+ expect(unchanged).not.toBeNull();
2819
+ expect(unchanged!.status).toBe('pending');
2820
+ });
2648
2821
  });
@@ -7,6 +7,7 @@ const runDeterministicChecksMock = mock();
7
7
  const createEventMock = mock();
8
8
  const updateEventDedupeKeyMock = mock();
9
9
  const dispatchDecisionMock = mock();
10
+ const activeBindingChannels = new Set<string>(['telegram']);
10
11
 
11
12
  mock.module('../util/logger.js', () => ({
12
13
  getLogger: () =>
@@ -21,7 +22,7 @@ mock.module('../channels/config.js', () => ({
21
22
 
22
23
  mock.module('../memory/channel-guardian-store.js', () => ({
23
24
  getActiveBinding: (_assistantId: string, channel: string) =>
24
- channel === 'telegram'
25
+ activeBindingChannels.has(channel)
25
26
  ? {
26
27
  guardianDeliveryChatId: 'guardian-chat-123',
27
28
  guardianExternalUserId: 'guardian-user-123',
@@ -83,6 +84,8 @@ describe('emitNotificationSignal routing intent re-persistence', () => {
83
84
  createEventMock.mockReset();
84
85
  updateEventDedupeKeyMock.mockReset();
85
86
  dispatchDecisionMock.mockReset();
87
+ activeBindingChannels.clear();
88
+ activeBindingChannels.add('telegram');
86
89
 
87
90
  createEventMock.mockReturnValue({ id: 'evt-1' });
88
91
  runDeterministicChecksMock.mockResolvedValue({ passed: true });
@@ -176,4 +179,43 @@ describe('emitNotificationSignal routing intent re-persistence', () => {
176
179
 
177
180
  expect(updateDecisionMock).not.toHaveBeenCalled();
178
181
  });
182
+
183
+ test('excludes unverified binding channels from connected channel candidates', async () => {
184
+ activeBindingChannels.clear();
185
+
186
+ const decision = {
187
+ shouldNotify: true,
188
+ selectedChannels: ['vellum'],
189
+ reasoningSummary: 'Local only',
190
+ renderedCopy: {
191
+ vellum: { title: 'Reminder', body: 'Check this' },
192
+ },
193
+ dedupeKey: 'dedupe-rem-3',
194
+ confidence: 0.8,
195
+ fallbackUsed: false,
196
+ persistedDecisionId: 'dec-3',
197
+ };
198
+
199
+ evaluateSignalMock.mockResolvedValue(decision);
200
+ enforceRoutingIntentMock.mockImplementation((inputDecision: unknown) => inputDecision);
201
+
202
+ await emitNotificationSignal({
203
+ sourceEventName: 'reminder.fired',
204
+ sourceChannel: 'scheduler',
205
+ sourceSessionId: 'rem-3',
206
+ attentionHints: {
207
+ requiresAction: false,
208
+ urgency: 'medium',
209
+ isAsyncBackground: false,
210
+ visibleInSourceNow: false,
211
+ },
212
+ contextPayload: { reminderId: 'rem-3' },
213
+ routingIntent: 'single_channel',
214
+ });
215
+
216
+ expect(evaluateSignalMock).toHaveBeenCalled();
217
+ const callArgs = evaluateSignalMock.mock.calls[0];
218
+ expect(callArgs).toBeDefined();
219
+ expect(callArgs?.[1]).toEqual(['vellum']);
220
+ });
179
221
  });