@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,571 @@
1
+ /**
2
+ * Tests for M4: voice consumer checks scoped grants before auto-denying
3
+ * non-guardian confirmation requests.
4
+ *
5
+ * Verifies:
6
+ * 1. A matching grant allows a non-guardian voice confirmation (exactly once).
7
+ * 2. No grant or mismatched grant still auto-denies.
8
+ * 3. Guardian auto-allow path remains unchanged.
9
+ * 4. Grants are revoked on call end (controller.destroy).
10
+ * 5. Second identical invocation after consume is denied (one-time use).
11
+ */
12
+
13
+ import { mkdtempSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ import { afterAll, beforeEach, describe, expect, type Mock, mock, test } from 'bun:test';
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), 'voice-scoped-grant-consumer-test-'));
20
+
21
+ // ── Platform + logger mocks (must come before any source imports) ────
22
+
23
+ mock.module('../util/platform.js', () => ({
24
+ getRootDir: () => testDir,
25
+ getDataDir: () => testDir,
26
+ isMacOS: () => process.platform === 'darwin',
27
+ isLinux: () => process.platform === 'linux',
28
+ isWindows: () => process.platform === 'win32',
29
+ getSocketPath: () => join(testDir, 'test.sock'),
30
+ getPidPath: () => join(testDir, 'test.pid'),
31
+ getDbPath: () => join(testDir, 'test.db'),
32
+ getLogPath: () => join(testDir, 'test.log'),
33
+ ensureDataDir: () => {},
34
+ readHttpToken: () => null,
35
+ }));
36
+
37
+ mock.module('../util/logger.js', () => ({
38
+ getLogger: () =>
39
+ new Proxy({} as Record<string, unknown>, {
40
+ get: () => () => {},
41
+ }),
42
+ isDebug: () => false,
43
+ truncateForLog: (value: string) => value,
44
+ }));
45
+
46
+ // ── Config mock ─────────────────────────────────────────────────────
47
+
48
+ mock.module('../config/loader.js', () => ({
49
+ getConfig: () => ({
50
+ provider: 'anthropic',
51
+ providerOrder: ['anthropic'],
52
+ apiKeys: { anthropic: 'test-key' },
53
+ calls: {
54
+ enabled: true,
55
+ provider: 'twilio',
56
+ maxDurationSeconds: 12 * 60,
57
+ userConsultTimeoutSeconds: 90,
58
+ userConsultationTimeoutSeconds: 90,
59
+ silenceTimeoutSeconds: 30,
60
+ disclosure: { enabled: false, text: '' },
61
+ safety: { denyCategories: [] },
62
+ model: undefined,
63
+ },
64
+ memory: { enabled: false },
65
+ }),
66
+ }));
67
+
68
+ // ── Secret ingress mock ────────────────────────────────────────────
69
+
70
+ mock.module('../security/secret-ingress.js', () => ({
71
+ checkIngressForSecrets: () => ({ blocked: false }),
72
+ }));
73
+
74
+ // ── Assistant event hub mock ───────────────────────────────────────
75
+
76
+ mock.module('../runtime/assistant-event-hub.js', () => ({
77
+ assistantEventHub: {
78
+ publish: async () => {},
79
+ },
80
+ }));
81
+
82
+ mock.module('../runtime/assistant-event.js', () => ({
83
+ buildAssistantEvent: () => ({}),
84
+ }));
85
+
86
+ // ── Session runtime assembly mock ──────────────────────────────────
87
+
88
+ mock.module('../daemon/session-runtime-assembly.js', () => ({
89
+ resolveChannelCapabilities: () => ({
90
+ supportsRichText: false,
91
+ supportsDynamicUi: false,
92
+ supportsVoiceInput: true,
93
+ }),
94
+ }));
95
+
96
+
97
+ // ── Import source modules after all mocks are registered ────────────
98
+
99
+ import { and, eq } from 'drizzle-orm';
100
+
101
+ import {
102
+ createScopedApprovalGrant,
103
+ type CreateScopedApprovalGrantParams,
104
+ revokeScopedApprovalGrantsForContext,
105
+ } from '../memory/scoped-approval-grants.js';
106
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
107
+ import { conversations, scopedApprovalGrants } from '../memory/schema.js';
108
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
109
+ import type { ServerMessage } from '../daemon/ipc-protocol.js';
110
+ import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
111
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
112
+
113
+ initializeDb();
114
+
115
+ afterAll(() => {
116
+ resetDb();
117
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Mock session that triggers a confirmation_request on processMessage
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const TOOL_NAME = 'execute_shell';
125
+ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
126
+ const ASSISTANT_ID = 'self';
127
+ const CONVERSATION_ID = 'conv-voice-grant-test';
128
+ const CALL_SESSION_ID = 'call-session-voice-grant-test';
129
+
130
+ /**
131
+ * Create a mock session that, when runAgentLoop is called, emits a
132
+ * confirmation_request through the updateClient callback before completing.
133
+ */
134
+ function createMockSession(opts?: {
135
+ confirmationRequestId?: string;
136
+ toolName?: string;
137
+ toolInput?: Record<string, unknown>;
138
+ }) {
139
+ const requestId = opts?.confirmationRequestId ?? `req-${crypto.randomUUID()}`;
140
+ const toolName = opts?.toolName ?? TOOL_NAME;
141
+ const toolInput = opts?.toolInput ?? TOOL_INPUT;
142
+
143
+ let clientCallback: ((msg: ServerMessage) => void) | null = null;
144
+ let confirmationDecision: { requestId: string; decision: string; reason?: string } | null = null;
145
+
146
+ const session = {
147
+ isProcessing: () => false,
148
+ memoryPolicy: {},
149
+ setAssistantId: () => {},
150
+ setGuardianContext: () => {},
151
+ setCommandIntent: () => {},
152
+ setTurnChannelContext: () => {},
153
+ setChannelCapabilities: () => {},
154
+ setVoiceCallControlPrompt: () => {},
155
+ currentRequestId: requestId,
156
+ abort: () => {},
157
+ persistUserMessage: async () => 'msg-1',
158
+ updateClient: (cb: (msg: ServerMessage) => void, _reset?: boolean) => {
159
+ clientCallback = cb;
160
+ },
161
+ handleConfirmationResponse: (
162
+ reqId: string,
163
+ decision: string,
164
+ _pattern?: string,
165
+ _scope?: string,
166
+ reason?: string,
167
+ ) => {
168
+ confirmationDecision = { requestId: reqId, decision, reason };
169
+ },
170
+ handleSecretResponse: () => {},
171
+ runAgentLoop: async (
172
+ _content: string,
173
+ _messageId: string,
174
+ broadcastFn: (msg: ServerMessage) => void,
175
+ ) => {
176
+ // Emit a confirmation_request through the client callback
177
+ if (clientCallback) {
178
+ clientCallback({
179
+ type: 'confirmation_request',
180
+ requestId,
181
+ toolName,
182
+ input: toolInput,
183
+ riskLevel: 'medium',
184
+ allowlistOptions: [],
185
+ scopeOptions: [],
186
+ } as ServerMessage);
187
+ }
188
+ // Then complete the turn
189
+ broadcastFn({ type: 'message_complete' } as ServerMessage);
190
+ },
191
+ };
192
+
193
+ return {
194
+ session,
195
+ requestId,
196
+ getConfirmationDecision: () => confirmationDecision,
197
+ };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Setup: inject mock deps into voice-session-bridge
202
+ // ---------------------------------------------------------------------------
203
+
204
+ function setupBridgeDeps(sessionFactory: () => ReturnType<typeof createMockSession>['session']) {
205
+ let currentSession: ReturnType<typeof createMockSession>['session'] | null = null;
206
+ setVoiceBridgeDeps({
207
+ getOrCreateSession: async () => {
208
+ currentSession = sessionFactory();
209
+ return currentSession as any;
210
+ },
211
+ resolveAttachments: () => [],
212
+ deriveDefaultStrictSideEffects: () => true,
213
+ });
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Helpers
218
+ // ---------------------------------------------------------------------------
219
+
220
+ function clearTables(): void {
221
+ const db = getDb();
222
+ try { db.run('DELETE FROM scoped_approval_grants'); } catch { /* table may not exist */ }
223
+ }
224
+
225
+ function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
226
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
227
+ return {
228
+ assistantId: ASSISTANT_ID,
229
+ scopeMode: 'tool_signature',
230
+ toolName: TOOL_NAME,
231
+ inputDigest: computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT),
232
+ requestChannel: 'voice',
233
+ decisionChannel: 'telegram',
234
+ executionChannel: 'voice',
235
+ conversationId: CONVERSATION_ID,
236
+ callSessionId: CALL_SESSION_ID,
237
+ expiresAt: futureExpiry,
238
+ ...overrides,
239
+ };
240
+ }
241
+
242
+ // ===========================================================================
243
+ // Tests
244
+ // ===========================================================================
245
+
246
+ describe('voice scoped grant consumer', () => {
247
+ beforeEach(() => {
248
+ clearTables();
249
+ });
250
+
251
+ test('non-guardian with matching grant: consumed and allowed', async () => {
252
+ // Create a matching grant
253
+ createScopedApprovalGrant(grantParams());
254
+
255
+ const mockData = createMockSession();
256
+ setupBridgeDeps(() => mockData.session);
257
+
258
+ const guardianContext: GuardianRuntimeContext = {
259
+ sourceChannel: 'voice',
260
+ actorRole: 'non-guardian',
261
+ requesterExternalUserId: 'caller-123',
262
+ };
263
+
264
+ const handle = await startVoiceTurn({
265
+ conversationId: CONVERSATION_ID,
266
+ callSessionId: CALL_SESSION_ID,
267
+ content: 'test utterance',
268
+ assistantId: ASSISTANT_ID,
269
+ guardianContext,
270
+ isInbound: true,
271
+ onTextDelta: () => {},
272
+ onComplete: () => {},
273
+ onError: () => {},
274
+ });
275
+
276
+ // Wait for the async agent loop to finish
277
+ await new Promise(resolve => setTimeout(resolve, 100));
278
+
279
+ const decision = mockData.getConfirmationDecision();
280
+ expect(decision).not.toBeNull();
281
+ expect(decision!.decision).toBe('allow');
282
+ expect(decision!.reason).toContain('scoped grant');
283
+ });
284
+
285
+ test('non-guardian without grant: auto-denied', async () => {
286
+ // No grant created
287
+
288
+ const mockData = createMockSession();
289
+ setupBridgeDeps(() => mockData.session);
290
+
291
+ const guardianContext: GuardianRuntimeContext = {
292
+ sourceChannel: 'voice',
293
+ actorRole: 'non-guardian',
294
+ requesterExternalUserId: 'caller-123',
295
+ };
296
+
297
+ const handle = await startVoiceTurn({
298
+ conversationId: CONVERSATION_ID,
299
+ callSessionId: CALL_SESSION_ID,
300
+ content: 'test utterance',
301
+ assistantId: ASSISTANT_ID,
302
+ guardianContext,
303
+ isInbound: true,
304
+ onTextDelta: () => {},
305
+ onComplete: () => {},
306
+ onError: () => {},
307
+ });
308
+
309
+ await new Promise(resolve => setTimeout(resolve, 100));
310
+
311
+ const decision = mockData.getConfirmationDecision();
312
+ expect(decision).not.toBeNull();
313
+ expect(decision!.decision).toBe('deny');
314
+ expect(decision!.reason).toContain('Permission denied');
315
+ });
316
+
317
+ test('non-guardian with mismatched tool name: auto-denied', async () => {
318
+ // Create a grant for a different tool
319
+ createScopedApprovalGrant(grantParams({
320
+ toolName: 'read_file',
321
+ inputDigest: computeToolApprovalDigest('read_file', TOOL_INPUT),
322
+ }));
323
+
324
+ const mockData = createMockSession();
325
+ setupBridgeDeps(() => mockData.session);
326
+
327
+ const guardianContext: GuardianRuntimeContext = {
328
+ sourceChannel: 'voice',
329
+ actorRole: 'non-guardian',
330
+ };
331
+
332
+ await startVoiceTurn({
333
+ conversationId: CONVERSATION_ID,
334
+ callSessionId: CALL_SESSION_ID,
335
+ content: 'test utterance',
336
+ assistantId: ASSISTANT_ID,
337
+ guardianContext,
338
+ isInbound: true,
339
+ onTextDelta: () => {},
340
+ onComplete: () => {},
341
+ onError: () => {},
342
+ });
343
+
344
+ await new Promise(resolve => setTimeout(resolve, 100));
345
+
346
+ const decision = mockData.getConfirmationDecision();
347
+ expect(decision).not.toBeNull();
348
+ expect(decision!.decision).toBe('deny');
349
+ });
350
+
351
+ test('guardian caller: auto-allowed regardless of grants', async () => {
352
+ // No grant needed — guardian should auto-allow
353
+
354
+ const mockData = createMockSession();
355
+ setupBridgeDeps(() => mockData.session);
356
+
357
+ const guardianContext: GuardianRuntimeContext = {
358
+ sourceChannel: 'voice',
359
+ actorRole: 'guardian',
360
+ };
361
+
362
+ await startVoiceTurn({
363
+ conversationId: CONVERSATION_ID,
364
+ callSessionId: CALL_SESSION_ID,
365
+ content: 'test utterance',
366
+ assistantId: ASSISTANT_ID,
367
+ guardianContext,
368
+ isInbound: true,
369
+ onTextDelta: () => {},
370
+ onComplete: () => {},
371
+ onError: () => {},
372
+ });
373
+
374
+ await new Promise(resolve => setTimeout(resolve, 100));
375
+
376
+ const decision = mockData.getConfirmationDecision();
377
+ expect(decision).not.toBeNull();
378
+ expect(decision!.decision).toBe('allow');
379
+ expect(decision!.reason).toContain('guardian voice call');
380
+ });
381
+
382
+ test('one-time use: second identical invocation after consume is denied', async () => {
383
+ // Create a single grant
384
+ createScopedApprovalGrant(grantParams());
385
+
386
+ // First invocation — should consume the grant and allow
387
+ const mockData1 = createMockSession({ confirmationRequestId: 'req-first' });
388
+ setupBridgeDeps(() => mockData1.session);
389
+
390
+ const guardianContext: GuardianRuntimeContext = {
391
+ sourceChannel: 'voice',
392
+ actorRole: 'non-guardian',
393
+ requesterExternalUserId: 'caller-123',
394
+ };
395
+
396
+ await startVoiceTurn({
397
+ conversationId: CONVERSATION_ID,
398
+ callSessionId: CALL_SESSION_ID,
399
+ content: 'first utterance',
400
+ assistantId: ASSISTANT_ID,
401
+ guardianContext,
402
+ isInbound: true,
403
+ onTextDelta: () => {},
404
+ onComplete: () => {},
405
+ onError: () => {},
406
+ });
407
+
408
+ await new Promise(resolve => setTimeout(resolve, 100));
409
+
410
+ const decision1 = mockData1.getConfirmationDecision();
411
+ expect(decision1).not.toBeNull();
412
+ expect(decision1!.decision).toBe('allow');
413
+
414
+ // Second invocation — grant already consumed, should deny
415
+ const mockData2 = createMockSession({ confirmationRequestId: 'req-second' });
416
+ setupBridgeDeps(() => mockData2.session);
417
+
418
+ await startVoiceTurn({
419
+ conversationId: CONVERSATION_ID,
420
+ callSessionId: CALL_SESSION_ID,
421
+ content: 'second utterance',
422
+ assistantId: ASSISTANT_ID,
423
+ guardianContext,
424
+ isInbound: true,
425
+ onTextDelta: () => {},
426
+ onComplete: () => {},
427
+ onError: () => {},
428
+ });
429
+
430
+ await new Promise(resolve => setTimeout(resolve, 100));
431
+
432
+ const decision2 = mockData2.getConfirmationDecision();
433
+ expect(decision2).not.toBeNull();
434
+ expect(decision2!.decision).toBe('deny');
435
+ });
436
+
437
+ test('grants revoked when revokeScopedApprovalGrantsForContext is called with callSessionId', () => {
438
+ const db = getDb();
439
+ const testCallSessionId = 'call-session-revoke-test';
440
+
441
+ // Create two grants: one for our call session, one for another
442
+ createScopedApprovalGrant(grantParams({ callSessionId: testCallSessionId }));
443
+ createScopedApprovalGrant(grantParams({ callSessionId: 'other-call-session' }));
444
+
445
+ // Verify both grants are active
446
+ const allActive = db.select()
447
+ .from(scopedApprovalGrants)
448
+ .where(eq(scopedApprovalGrants.status, 'active'))
449
+ .all();
450
+ expect(allActive.length).toBe(2);
451
+
452
+ // Revoke grants for the specific call session (simulates call end)
453
+ const revokedCount = revokeScopedApprovalGrantsForContext({ callSessionId: testCallSessionId });
454
+ expect(revokedCount).toBe(1);
455
+
456
+ // Only the target call session's grant should be revoked
457
+ const activeAfter = db.select()
458
+ .from(scopedApprovalGrants)
459
+ .where(and(
460
+ eq(scopedApprovalGrants.callSessionId, testCallSessionId),
461
+ eq(scopedApprovalGrants.status, 'active'),
462
+ ))
463
+ .all();
464
+ expect(activeAfter.length).toBe(0);
465
+
466
+ const revokedAfter = db.select()
467
+ .from(scopedApprovalGrants)
468
+ .where(and(
469
+ eq(scopedApprovalGrants.callSessionId, testCallSessionId),
470
+ eq(scopedApprovalGrants.status, 'revoked'),
471
+ ))
472
+ .all();
473
+ expect(revokedAfter.length).toBe(1);
474
+
475
+ // The other call session's grant should still be active
476
+ const otherActive = db.select()
477
+ .from(scopedApprovalGrants)
478
+ .where(and(
479
+ eq(scopedApprovalGrants.callSessionId, 'other-call-session'),
480
+ eq(scopedApprovalGrants.status, 'active'),
481
+ ))
482
+ .all();
483
+ expect(otherActive.length).toBe(1);
484
+ });
485
+
486
+ test('grants with null callSessionId are revoked by conversationId', () => {
487
+ const db = getDb();
488
+ const testConversationId = 'conv-revoke-by-conversation';
489
+
490
+ // Simulate the guardian-approval-interception minting path which sets
491
+ // callSessionId: null but always sets conversationId
492
+ createScopedApprovalGrant(grantParams({
493
+ callSessionId: null,
494
+ conversationId: testConversationId,
495
+ }));
496
+ createScopedApprovalGrant(grantParams({
497
+ callSessionId: null,
498
+ conversationId: 'other-conversation',
499
+ }));
500
+
501
+ // Verify both grants are active
502
+ const allActive = db.select()
503
+ .from(scopedApprovalGrants)
504
+ .where(eq(scopedApprovalGrants.status, 'active'))
505
+ .all();
506
+ expect(allActive.length).toBe(2);
507
+
508
+ // callSessionId-based revocation should miss grants with null callSessionId
509
+ // because the filter matches on the column value, not NULL
510
+ const revokedByCallSession = revokeScopedApprovalGrantsForContext({ callSessionId: CALL_SESSION_ID });
511
+ expect(revokedByCallSession).toBe(0);
512
+
513
+ // conversationId-based revocation catches the grant
514
+ const revokedByConversation = revokeScopedApprovalGrantsForContext({ conversationId: testConversationId });
515
+ expect(revokedByConversation).toBe(1);
516
+
517
+ // The target conversation's grant should be revoked
518
+ const revokedAfter = db.select()
519
+ .from(scopedApprovalGrants)
520
+ .where(and(
521
+ eq(scopedApprovalGrants.conversationId, testConversationId),
522
+ eq(scopedApprovalGrants.status, 'revoked'),
523
+ ))
524
+ .all();
525
+ expect(revokedAfter.length).toBe(1);
526
+
527
+ // The other conversation's grant should still be active
528
+ const otherActive = db.select()
529
+ .from(scopedApprovalGrants)
530
+ .where(and(
531
+ eq(scopedApprovalGrants.conversationId, 'other-conversation'),
532
+ eq(scopedApprovalGrants.status, 'active'),
533
+ ))
534
+ .all();
535
+ expect(otherActive.length).toBe(1);
536
+ });
537
+
538
+ test('non-guardian with grant for different assistantId: auto-denied', async () => {
539
+ // Create a grant scoped to a different assistant
540
+ createScopedApprovalGrant(grantParams({
541
+ assistantId: 'other-assistant',
542
+ }));
543
+
544
+ const mockData = createMockSession();
545
+ setupBridgeDeps(() => mockData.session);
546
+
547
+ const guardianContext: GuardianRuntimeContext = {
548
+ sourceChannel: 'voice',
549
+ actorRole: 'non-guardian',
550
+ requesterExternalUserId: 'caller-123',
551
+ };
552
+
553
+ await startVoiceTurn({
554
+ conversationId: CONVERSATION_ID,
555
+ callSessionId: CALL_SESSION_ID,
556
+ content: 'test utterance',
557
+ assistantId: ASSISTANT_ID,
558
+ guardianContext,
559
+ isInbound: true,
560
+ onTextDelta: () => {},
561
+ onComplete: () => {},
562
+ onError: () => {},
563
+ });
564
+
565
+ await new Promise(resolve => setTimeout(resolve, 100));
566
+
567
+ const decision = mockData.getConfirmationDecision();
568
+ expect(decision).not.toBeNull();
569
+ expect(decision!.decision).toBe('deny');
570
+ });
571
+ });