@vellumai/assistant 0.3.16 → 0.3.19

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 (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Tests for M3: scoped grant minting on guardian tool-approval decisions.
3
+ *
4
+ * When a guardian approves a tool-approval request (one with toolName + input),
5
+ * the approval interception flow should mint a `tool_signature` scoped grant.
6
+ * Non-tool-approval requests and rejections must NOT mint grants.
7
+ */
8
+
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Test isolation: in-memory SQLite via temp directory
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-grant-minting-test-'));
20
+
21
+ mock.module('../util/platform.js', () => ({
22
+ getRootDir: () => testDir,
23
+ getDataDir: () => testDir,
24
+ isMacOS: () => process.platform === 'darwin',
25
+ isLinux: () => process.platform === 'linux',
26
+ isWindows: () => process.platform === 'win32',
27
+ getSocketPath: () => join(testDir, 'test.sock'),
28
+ getPidPath: () => join(testDir, 'test.pid'),
29
+ getDbPath: () => join(testDir, 'test.db'),
30
+ getLogPath: () => join(testDir, 'test.log'),
31
+ ensureDataDir: () => {},
32
+ }));
33
+
34
+ mock.module('../util/logger.js', () => ({
35
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
36
+ get: () => () => {},
37
+ }),
38
+ }));
39
+
40
+ import type { Session } from '../daemon/session.js';
41
+ import {
42
+ createApprovalRequest,
43
+ createBinding,
44
+ getAllPendingApprovalsByGuardianChat,
45
+ type GuardianApprovalRequest,
46
+ } from '../memory/channel-guardian-store.js';
47
+ import { initializeDb, resetDb } from '../memory/db.js';
48
+ import * as scopedGrantStore from '../memory/scoped-approval-grants.js';
49
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
50
+ import * as approvalMessageComposer from '../runtime/approval-message-composer.js';
51
+ import * as gatewayClient from '../runtime/gateway-client.js';
52
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
53
+ import {
54
+ handleApprovalInterception,
55
+ GRANT_TTL_MS,
56
+ } from '../runtime/routes/guardian-approval-interception.js';
57
+ import type { GuardianContext } from '../runtime/routes/channel-route-shared.js';
58
+
59
+ initializeDb();
60
+
61
+ afterAll(() => {
62
+ resetDb();
63
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const ASSISTANT_ID = 'self';
71
+ const GUARDIAN_USER = 'guardian-user-1';
72
+ const GUARDIAN_CHAT = 'guardian-chat-1';
73
+ const REQUESTER_USER = 'requester-user-1';
74
+ const REQUESTER_CHAT = 'requester-chat-1';
75
+ const CONVERSATION_ID = 'conv-1';
76
+ const TOOL_NAME = 'execute_shell';
77
+ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
78
+
79
+ function resetTables(): void {
80
+ try {
81
+ const { getDb } = require('../memory/db.js');
82
+ const db = getDb();
83
+ db.run('DELETE FROM channel_guardian_approval_requests');
84
+ db.run('DELETE FROM scoped_approval_grants');
85
+ } catch { /* tables may not exist yet */ }
86
+ pendingInteractions.clear();
87
+ }
88
+
89
+ function createTestGuardianApproval(
90
+ requestId: string,
91
+ overrides: Partial<Parameters<typeof createApprovalRequest>[0]> = {},
92
+ ): GuardianApprovalRequest {
93
+ return createApprovalRequest({
94
+ runId: `run-${requestId}`,
95
+ requestId,
96
+ conversationId: CONVERSATION_ID,
97
+ assistantId: ASSISTANT_ID,
98
+ channel: 'telegram',
99
+ requesterExternalUserId: REQUESTER_USER,
100
+ requesterChatId: REQUESTER_CHAT,
101
+ guardianExternalUserId: GUARDIAN_USER,
102
+ guardianChatId: GUARDIAN_CHAT,
103
+ toolName: TOOL_NAME,
104
+ expiresAt: Date.now() + 300_000,
105
+ ...overrides,
106
+ });
107
+ }
108
+
109
+ function registerPendingInteraction(
110
+ requestId: string,
111
+ conversationId: string,
112
+ toolName: string,
113
+ input: Record<string, unknown> = TOOL_INPUT,
114
+ ): ReturnType<typeof mock> {
115
+ const handleConfirmationResponse = mock(() => {});
116
+ const mockSession = {
117
+ handleConfirmationResponse,
118
+ } as unknown as Session;
119
+
120
+ pendingInteractions.register(requestId, {
121
+ session: mockSession,
122
+ conversationId,
123
+ kind: 'confirmation',
124
+ confirmationDetails: {
125
+ toolName,
126
+ input,
127
+ riskLevel: 'high',
128
+ allowlistOptions: [
129
+ { label: 'test', description: 'test', pattern: 'test' },
130
+ ],
131
+ scopeOptions: [
132
+ { label: 'everywhere', scope: 'everywhere' },
133
+ ],
134
+ },
135
+ });
136
+
137
+ return handleConfirmationResponse;
138
+ }
139
+
140
+ function makeGuardianContext(): GuardianContext {
141
+ return {
142
+ actorRole: 'guardian',
143
+ denialReason: undefined,
144
+ };
145
+ }
146
+
147
+ function makeNonGuardianContext(): GuardianContext {
148
+ return {
149
+ actorRole: 'non-guardian',
150
+ denialReason: undefined,
151
+ };
152
+ }
153
+
154
+ function countGrants(): number {
155
+ try {
156
+ const { getDb } = require('../memory/db.js');
157
+ const db = getDb();
158
+ const row = db.$client.prepare('SELECT count(*) as cnt FROM scoped_approval_grants').get() as { cnt: number };
159
+ return row.cnt;
160
+ } catch {
161
+ return 0;
162
+ }
163
+ }
164
+
165
+ function getLatestGrant(): Record<string, unknown> | null {
166
+ try {
167
+ const { getDb } = require('../memory/db.js');
168
+ const db = getDb();
169
+ const row = db.$client.prepare('SELECT * FROM scoped_approval_grants ORDER BY created_at DESC LIMIT 1').get();
170
+ return (row as Record<string, unknown>) ?? null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+ // Tests
178
+ // ═══════════════════════════════════════════════════════════════════════════
179
+
180
+ describe('guardian grant minting on tool-approval decisions', () => {
181
+ let deliverSpy: ReturnType<typeof spyOn>;
182
+ let composeSpy: ReturnType<typeof spyOn>;
183
+
184
+ beforeEach(() => {
185
+ resetTables();
186
+ deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
187
+ composeSpy = spyOn(approvalMessageComposer, 'composeApprovalMessageGenerative')
188
+ .mockResolvedValue('test message');
189
+ });
190
+
191
+ // ── 1. approve_once via callback mints a grant ──
192
+
193
+ test('approve_once via callback for tool-approval request mints a scoped grant', async () => {
194
+ const requestId = 'req-grant-cb-1';
195
+ createTestGuardianApproval(requestId);
196
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
197
+
198
+ const result = await handleApprovalInterception({
199
+ conversationId: 'guardian-conv-1',
200
+ callbackData: `apr:${requestId}:approve_once`,
201
+ content: '',
202
+ externalChatId: GUARDIAN_CHAT,
203
+ sourceChannel: 'telegram',
204
+ senderExternalUserId: GUARDIAN_USER,
205
+ replyCallbackUrl: 'https://gateway.test/deliver',
206
+ guardianCtx: makeGuardianContext(),
207
+ assistantId: ASSISTANT_ID,
208
+ });
209
+
210
+ expect(result.handled).toBe(true);
211
+ expect(result.type).toBe('guardian_decision_applied');
212
+
213
+ // Verify a grant was minted
214
+ expect(countGrants()).toBe(1);
215
+
216
+ const grant = getLatestGrant();
217
+ expect(grant).not.toBeNull();
218
+ expect(grant!.scope_mode).toBe('tool_signature');
219
+ expect(grant!.tool_name).toBe(TOOL_NAME);
220
+ expect(grant!.status).toBe('active');
221
+ expect(grant!.request_channel).toBe('telegram');
222
+ expect(grant!.decision_channel).toBe('telegram');
223
+ expect(grant!.guardian_external_user_id).toBe(GUARDIAN_USER);
224
+ expect(grant!.requester_external_user_id).toBe(REQUESTER_USER);
225
+ expect(grant!.conversation_id).toBe(CONVERSATION_ID);
226
+ expect(grant!.execution_channel).toBeNull();
227
+ expect(grant!.call_session_id).toBeNull();
228
+
229
+ // Verify the input digest matches what computeToolApprovalDigest produces
230
+ const expectedDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
231
+ expect(grant!.input_digest).toBe(expectedDigest);
232
+
233
+ deliverSpy.mockRestore();
234
+ composeSpy.mockRestore();
235
+ });
236
+
237
+ // ── 2. approve_once for non-tool-approval does NOT mint a grant ──
238
+
239
+ test('approve_once for informational request (no toolName) does NOT mint a grant', async () => {
240
+ const requestId = 'req-no-grant-1';
241
+ // Informational requests have no meaningful tool name — the empty string
242
+ // signals that this is not a tool-approval request.
243
+ createTestGuardianApproval(requestId, { toolName: '' });
244
+ registerPendingInteraction(requestId, CONVERSATION_ID, '', {});
245
+
246
+ const result = await handleApprovalInterception({
247
+ conversationId: 'guardian-conv-2',
248
+ callbackData: `apr:${requestId}:approve_once`,
249
+ content: '',
250
+ externalChatId: GUARDIAN_CHAT,
251
+ sourceChannel: 'telegram',
252
+ senderExternalUserId: GUARDIAN_USER,
253
+ replyCallbackUrl: 'https://gateway.test/deliver',
254
+ guardianCtx: makeGuardianContext(),
255
+ assistantId: ASSISTANT_ID,
256
+ });
257
+
258
+ expect(result.handled).toBe(true);
259
+ expect(result.type).toBe('guardian_decision_applied');
260
+
261
+ // No grant should have been minted
262
+ expect(countGrants()).toBe(0);
263
+
264
+ deliverSpy.mockRestore();
265
+ composeSpy.mockRestore();
266
+ });
267
+
268
+ // ── 2b. approve_once for zero-argument tool call DOES mint a grant ──
269
+
270
+ test('approve_once for zero-argument tool call mints a scoped grant', async () => {
271
+ const requestId = 'req-grant-zero-arg';
272
+ const zeroArgTool = 'get_system_status';
273
+ createTestGuardianApproval(requestId, { toolName: zeroArgTool });
274
+ // Register with empty input object to simulate a zero-argument tool call
275
+ registerPendingInteraction(requestId, CONVERSATION_ID, zeroArgTool, {});
276
+
277
+ const result = await handleApprovalInterception({
278
+ conversationId: 'guardian-conv-2b',
279
+ callbackData: `apr:${requestId}:approve_once`,
280
+ content: '',
281
+ externalChatId: GUARDIAN_CHAT,
282
+ sourceChannel: 'telegram',
283
+ senderExternalUserId: GUARDIAN_USER,
284
+ replyCallbackUrl: 'https://gateway.test/deliver',
285
+ guardianCtx: makeGuardianContext(),
286
+ assistantId: ASSISTANT_ID,
287
+ });
288
+
289
+ expect(result.handled).toBe(true);
290
+ expect(result.type).toBe('guardian_decision_applied');
291
+
292
+ // A grant MUST be minted even though input is {}
293
+ expect(countGrants()).toBe(1);
294
+
295
+ const grant = getLatestGrant();
296
+ expect(grant).not.toBeNull();
297
+ expect(grant!.scope_mode).toBe('tool_signature');
298
+ expect(grant!.tool_name).toBe(zeroArgTool);
299
+ expect(grant!.status).toBe('active');
300
+
301
+ // Verify the input digest matches what computeToolApprovalDigest produces for empty input
302
+ const expectedDigest = computeToolApprovalDigest(zeroArgTool, {});
303
+ expect(grant!.input_digest).toBe(expectedDigest);
304
+
305
+ deliverSpy.mockRestore();
306
+ composeSpy.mockRestore();
307
+ });
308
+
309
+ // ── 3. reject does NOT mint a grant ──
310
+
311
+ test('reject decision does NOT mint a scoped grant', async () => {
312
+ const requestId = 'req-no-grant-rej';
313
+ createTestGuardianApproval(requestId);
314
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
315
+
316
+ const result = await handleApprovalInterception({
317
+ conversationId: 'guardian-conv-3',
318
+ callbackData: `apr:${requestId}:reject`,
319
+ content: '',
320
+ externalChatId: GUARDIAN_CHAT,
321
+ sourceChannel: 'telegram',
322
+ senderExternalUserId: GUARDIAN_USER,
323
+ replyCallbackUrl: 'https://gateway.test/deliver',
324
+ guardianCtx: makeGuardianContext(),
325
+ assistantId: ASSISTANT_ID,
326
+ });
327
+
328
+ expect(result.handled).toBe(true);
329
+ expect(result.type).toBe('guardian_decision_applied');
330
+
331
+ // No grant should have been minted
332
+ expect(countGrants()).toBe(0);
333
+
334
+ deliverSpy.mockRestore();
335
+ composeSpy.mockRestore();
336
+ });
337
+
338
+ // ── 4. Identity mismatch remains fail-closed (no grant minted) ──
339
+
340
+ test('identity mismatch does NOT mint a grant and fails closed', async () => {
341
+ const requestId = 'req-mismatch-1';
342
+ createTestGuardianApproval(requestId);
343
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
344
+
345
+ const result = await handleApprovalInterception({
346
+ conversationId: 'guardian-conv-4',
347
+ callbackData: `apr:${requestId}:approve_once`,
348
+ content: '',
349
+ externalChatId: GUARDIAN_CHAT,
350
+ sourceChannel: 'telegram',
351
+ senderExternalUserId: 'wrong-guardian-user',
352
+ replyCallbackUrl: 'https://gateway.test/deliver',
353
+ guardianCtx: makeGuardianContext(),
354
+ assistantId: ASSISTANT_ID,
355
+ });
356
+
357
+ expect(result.handled).toBe(true);
358
+ // Identity mismatch results in guardian_decision_applied (fail-closed, no actual decision applied)
359
+ expect(result.type).toBe('guardian_decision_applied');
360
+
361
+ // No grant should have been minted
362
+ expect(countGrants()).toBe(0);
363
+
364
+ deliverSpy.mockRestore();
365
+ composeSpy.mockRestore();
366
+ });
367
+
368
+ // ── 5. Stale/already-resolved request does NOT mint a grant ──
369
+
370
+ test('stale request (already resolved) does NOT mint a grant', async () => {
371
+ const requestId = 'req-stale-1';
372
+ // Create guardian approval but do NOT register a pending interaction
373
+ // This simulates the pending interaction being already resolved
374
+ createTestGuardianApproval(requestId);
375
+
376
+ const result = await handleApprovalInterception({
377
+ conversationId: 'guardian-conv-5',
378
+ callbackData: `apr:${requestId}:approve_once`,
379
+ content: '',
380
+ externalChatId: GUARDIAN_CHAT,
381
+ sourceChannel: 'telegram',
382
+ senderExternalUserId: GUARDIAN_USER,
383
+ replyCallbackUrl: 'https://gateway.test/deliver',
384
+ guardianCtx: makeGuardianContext(),
385
+ assistantId: ASSISTANT_ID,
386
+ });
387
+
388
+ expect(result.handled).toBe(true);
389
+ expect(result.type).toBe('stale_ignored');
390
+
391
+ // No grant should have been minted
392
+ expect(countGrants()).toBe(0);
393
+
394
+ deliverSpy.mockRestore();
395
+ composeSpy.mockRestore();
396
+ });
397
+
398
+ // ── 6. approve_once via conversation engine mints a grant ──
399
+
400
+ test('approve_once via conversation engine mints a scoped grant', async () => {
401
+ const requestId = 'req-grant-eng-1';
402
+ createTestGuardianApproval(requestId);
403
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
404
+
405
+ const mockGenerator = async () => ({
406
+ disposition: 'approve_once' as const,
407
+ replyText: 'Approved!',
408
+ targetRequestId: requestId,
409
+ });
410
+
411
+ const result = await handleApprovalInterception({
412
+ conversationId: 'guardian-conv-6',
413
+ content: 'yes, approve it',
414
+ externalChatId: GUARDIAN_CHAT,
415
+ sourceChannel: 'telegram',
416
+ senderExternalUserId: GUARDIAN_USER,
417
+ replyCallbackUrl: 'https://gateway.test/deliver',
418
+ guardianCtx: makeGuardianContext(),
419
+ assistantId: ASSISTANT_ID,
420
+ approvalConversationGenerator: mockGenerator,
421
+ });
422
+
423
+ expect(result.handled).toBe(true);
424
+ expect(result.type).toBe('guardian_decision_applied');
425
+
426
+ // Verify a grant was minted
427
+ expect(countGrants()).toBe(1);
428
+
429
+ const grant = getLatestGrant();
430
+ expect(grant).not.toBeNull();
431
+ expect(grant!.scope_mode).toBe('tool_signature');
432
+ expect(grant!.tool_name).toBe(TOOL_NAME);
433
+ expect(grant!.status).toBe('active');
434
+
435
+ deliverSpy.mockRestore();
436
+ composeSpy.mockRestore();
437
+ });
438
+
439
+ // ── 7. reject via conversation engine does NOT mint a grant ──
440
+
441
+ test('reject via conversation engine does NOT mint a grant', async () => {
442
+ const requestId = 'req-no-grant-eng-rej';
443
+ createTestGuardianApproval(requestId);
444
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
445
+
446
+ const mockGenerator = async () => ({
447
+ disposition: 'reject' as const,
448
+ replyText: 'Denied.',
449
+ targetRequestId: requestId,
450
+ });
451
+
452
+ const result = await handleApprovalInterception({
453
+ conversationId: 'guardian-conv-7',
454
+ content: 'no, deny it',
455
+ externalChatId: GUARDIAN_CHAT,
456
+ sourceChannel: 'telegram',
457
+ senderExternalUserId: GUARDIAN_USER,
458
+ replyCallbackUrl: 'https://gateway.test/deliver',
459
+ guardianCtx: makeGuardianContext(),
460
+ assistantId: ASSISTANT_ID,
461
+ approvalConversationGenerator: mockGenerator,
462
+ });
463
+
464
+ expect(result.handled).toBe(true);
465
+ expect(result.type).toBe('guardian_decision_applied');
466
+
467
+ // No grant should have been minted
468
+ expect(countGrants()).toBe(0);
469
+
470
+ deliverSpy.mockRestore();
471
+ composeSpy.mockRestore();
472
+ });
473
+
474
+ // ── 8. approve_once via legacy parser mints a grant ──
475
+
476
+ test('approve_once via legacy parser mints a scoped grant', async () => {
477
+ const requestId = 'req-grant-leg-1';
478
+ createTestGuardianApproval(requestId);
479
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
480
+
481
+ // No approvalConversationGenerator => legacy parser path
482
+ const result = await handleApprovalInterception({
483
+ conversationId: 'guardian-conv-8',
484
+ content: 'yes',
485
+ externalChatId: GUARDIAN_CHAT,
486
+ sourceChannel: 'telegram',
487
+ senderExternalUserId: GUARDIAN_USER,
488
+ replyCallbackUrl: 'https://gateway.test/deliver',
489
+ guardianCtx: makeGuardianContext(),
490
+ assistantId: ASSISTANT_ID,
491
+ });
492
+
493
+ expect(result.handled).toBe(true);
494
+ expect(result.type).toBe('guardian_decision_applied');
495
+
496
+ // Verify a grant was minted
497
+ expect(countGrants()).toBe(1);
498
+
499
+ const grant = getLatestGrant();
500
+ expect(grant).not.toBeNull();
501
+ expect(grant!.scope_mode).toBe('tool_signature');
502
+ expect(grant!.tool_name).toBe(TOOL_NAME);
503
+
504
+ deliverSpy.mockRestore();
505
+ composeSpy.mockRestore();
506
+ });
507
+
508
+ // ── 9. Grant TTL is approximately 5 minutes ──
509
+
510
+ test('minted grant has approximately 5-minute TTL', async () => {
511
+ const requestId = 'req-grant-ttl-1';
512
+ createTestGuardianApproval(requestId);
513
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
514
+
515
+ const beforeTime = Date.now();
516
+
517
+ const result = await handleApprovalInterception({
518
+ conversationId: 'guardian-conv-9',
519
+ callbackData: `apr:${requestId}:approve_once`,
520
+ content: '',
521
+ externalChatId: GUARDIAN_CHAT,
522
+ sourceChannel: 'telegram',
523
+ senderExternalUserId: GUARDIAN_USER,
524
+ replyCallbackUrl: 'https://gateway.test/deliver',
525
+ guardianCtx: makeGuardianContext(),
526
+ assistantId: ASSISTANT_ID,
527
+ });
528
+
529
+ expect(result.type).toBe('guardian_decision_applied');
530
+
531
+ const grant = getLatestGrant();
532
+ expect(grant).not.toBeNull();
533
+
534
+ const expiresAt = new Date(grant!.expires_at as string).getTime();
535
+ const expectedMin = beforeTime + GRANT_TTL_MS - 1000; // 1s tolerance
536
+ const expectedMax = beforeTime + GRANT_TTL_MS + 5000; // 5s tolerance
537
+ expect(expiresAt).toBeGreaterThanOrEqual(expectedMin);
538
+ expect(expiresAt).toBeLessThanOrEqual(expectedMax);
539
+
540
+ deliverSpy.mockRestore();
541
+ composeSpy.mockRestore();
542
+ });
543
+ });
@@ -727,6 +727,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
727
727
  type: 'heartbeat_checklist_write',
728
728
  content: '- [ ] Check email\n- [ ] Review PRs',
729
729
  },
730
+ voice_config_update: {
731
+ type: 'voice_config_update',
732
+ activationKey: 'fn',
733
+ },
730
734
  };
731
735
 
732
736
  // ---------------------------------------------------------------------------
@@ -1077,6 +1081,7 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1077
1081
  degraded: false,
1078
1082
  updateAvailable: false,
1079
1083
  userInvocable: true,
1084
+ provenance: { kind: 'first-party', provider: 'Vellum' },
1080
1085
  },
1081
1086
  ],
1082
1087
  },
@@ -1998,6 +2003,23 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1998
2003
  type: 'heartbeat_checklist_write_response',
1999
2004
  success: true,
2000
2005
  },
2006
+ navigate_settings: {
2007
+ type: 'navigate_settings',
2008
+ tab: 'general',
2009
+ },
2010
+ client_settings_update: {
2011
+ type: 'client_settings_update',
2012
+ key: 'activationKey',
2013
+ value: 'fn',
2014
+ },
2015
+ identity_changed: {
2016
+ type: 'identity_changed',
2017
+ name: 'Vellum',
2018
+ role: 'assistant',
2019
+ personality: 'friendly',
2020
+ emoji: '',
2021
+ home: '',
2022
+ },
2001
2023
  };
2002
2024
 
2003
2025
  // ---------------------------------------------------------------------------
@@ -84,7 +84,7 @@ import {
84
84
  createBinding,
85
85
  findPendingAccessRequestForRequester,
86
86
  } from '../memory/channel-guardian-store.js';
87
- import { initializeDb, resetDb } from '../memory/db.js';
87
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
88
88
  import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
89
89
 
90
90
  initializeDb();
@@ -101,7 +101,6 @@ afterAll(() => {
101
101
  const TEST_BEARER_TOKEN = 'test-token';
102
102
 
103
103
  function resetState(): void {
104
- const { getDb } = require('../memory/db.js');
105
104
  const db = getDb();
106
105
  db.run('DELETE FROM channel_guardian_approval_requests');
107
106
  db.run('DELETE FROM channel_guardian_bindings');