@vellumai/assistant 0.3.18 → 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 (42) hide show
  1. package/ARCHITECTURE.md +4 -0
  2. package/docs/architecture/security.md +80 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  5. package/src/__tests__/call-controller.test.ts +170 -0
  6. package/src/__tests__/checker.test.ts +60 -0
  7. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  8. package/src/__tests__/guardian-dispatch.test.ts +61 -1
  9. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  10. package/src/__tests__/ipc-snapshot.test.ts +1 -0
  11. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  12. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  13. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  14. package/src/__tests__/trust-store.test.ts +2 -0
  15. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  16. package/src/calls/call-controller.ts +27 -6
  17. package/src/calls/call-domain.ts +12 -0
  18. package/src/calls/guardian-dispatch.ts +8 -0
  19. package/src/calls/relay-server.ts +13 -0
  20. package/src/calls/voice-session-bridge.ts +42 -3
  21. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  22. package/src/config/schema.ts +6 -0
  23. package/src/config/skills-schema.ts +27 -0
  24. package/src/daemon/handlers/config-channels.ts +18 -0
  25. package/src/daemon/handlers/skills.ts +45 -2
  26. package/src/daemon/ipc-contract/skills.ts +1 -0
  27. package/src/daemon/session-process.ts +12 -0
  28. package/src/memory/db-init.ts +9 -1
  29. package/src/memory/embedding-local.ts +16 -7
  30. package/src/memory/guardian-action-store.ts +8 -0
  31. package/src/memory/guardian-verification.ts +1 -1
  32. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  33. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  34. package/src/memory/migrations/index.ts +2 -0
  35. package/src/memory/schema.ts +30 -0
  36. package/src/memory/scoped-approval-grants.ts +509 -0
  37. package/src/permissions/checker.ts +27 -0
  38. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  39. package/src/runtime/routes/guardian-approval-interception.ts +116 -0
  40. package/src/runtime/routes/inbound-message-handler.ts +94 -27
  41. package/src/security/tool-approval-digest.ts +67 -0
  42. package/src/skills/remote-skill-policy.ts +131 -0
@@ -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
+ });
@@ -16,6 +16,7 @@ import {
16
16
  getPendingRequestByCallSessionId,
17
17
  markTimedOutWithReason,
18
18
  } from '../memory/guardian-action-store.js';
19
+ import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
19
20
  import { getLogger } from '../util/logger.js';
20
21
  import { readHttpToken } from '../util/platform.js';
21
22
  import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
@@ -29,6 +30,7 @@ import {
29
30
  recordCallEvent,
30
31
  updateCallSession,
31
32
  } from './call-store.js';
33
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
32
34
  import { sendGuardianExpiryNotices } from './guardian-action-sweep.js';
33
35
  import { dispatchGuardianQuestion } from './guardian-dispatch.js';
34
36
  import type { RelayConnection } from './relay-server.js';
@@ -436,6 +438,21 @@ export class CallController {
436
438
  this.abortCurrentTurn();
437
439
  this.currentTurnPromise = null;
438
440
  unregisterCallController(this.callSessionId);
441
+
442
+ // Revoke any scoped approval grants bound to this call session.
443
+ // Revoke by both callSessionId and conversationId because the
444
+ // guardian-approval-interception minting path sets callSessionId: null
445
+ // but always sets conversationId.
446
+ try {
447
+ let revoked = revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
448
+ revoked += revokeScopedApprovalGrantsForContext({ conversationId: this.conversationId });
449
+ if (revoked > 0) {
450
+ log.info({ callSessionId: this.callSessionId, conversationId: this.conversationId, revokedCount: revoked }, 'Revoked scoped grants on call end');
451
+ }
452
+ } catch (err) {
453
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on call end');
454
+ }
455
+
439
456
  log.info({ callSessionId: this.callSessionId }, 'CallController destroyed');
440
457
  }
441
458
 
@@ -574,6 +591,7 @@ export class CallController {
574
591
  // Start the voice turn through the session bridge
575
592
  startVoiceTurn({
576
593
  conversationId: this.conversationId,
594
+ callSessionId: this.callSessionId,
577
595
  content,
578
596
  assistantId: this.assistantId,
579
597
  guardianContext: this.guardianContext ?? undefined,
@@ -635,23 +653,24 @@ export class CallController {
635
653
  // `}]` inside JSON string values does not truncate the payload or
636
654
  // leak partial JSON into TTS output.
637
655
  const approvalMatch = extractBalancedJson(responseText);
638
- let approvalQuestion: string | null = null;
656
+ let toolApprovalMeta: { question: string; toolName: string; inputDigest: string } | null = null;
639
657
  if (approvalMatch) {
640
658
  try {
641
- const parsed = JSON.parse(approvalMatch.json) as { question?: string };
642
- if (parsed.question) {
643
- approvalQuestion = parsed.question;
659
+ const parsed = JSON.parse(approvalMatch.json) as { question?: string; toolName?: string; input?: Record<string, unknown> };
660
+ if (parsed.question && parsed.toolName && parsed.input) {
661
+ const digest = computeToolApprovalDigest(parsed.toolName, parsed.input);
662
+ toolApprovalMeta = { question: parsed.question, toolName: parsed.toolName, inputDigest: digest };
644
663
  }
645
664
  } catch {
646
665
  log.warn({ callSessionId: this.callSessionId }, 'Failed to parse ASK_GUARDIAN_APPROVAL JSON payload');
647
666
  }
648
667
  }
649
668
 
650
- const askMatch = approvalQuestion
669
+ const askMatch = toolApprovalMeta
651
670
  ? null // structured approval takes precedence
652
671
  : responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
653
672
 
654
- const questionText = approvalQuestion ?? (askMatch ? askMatch[1] : null);
673
+ const questionText = toolApprovalMeta?.question ?? (askMatch ? askMatch[1] : null);
655
674
 
656
675
  if (questionText) {
657
676
  if (this.isCallerGuardian()) {
@@ -690,6 +709,8 @@ export class CallController {
690
709
  conversationId: session.conversationId,
691
710
  assistantId: this.assistantId,
692
711
  pendingQuestion,
712
+ toolName: toolApprovalMeta?.toolName,
713
+ inputDigest: toolApprovalMeta?.inputDigest,
693
714
  });
694
715
  }
695
716
 
@@ -13,6 +13,7 @@ import { getTwilioStatusCallbackUrl,getTwilioVoiceWebhookUrl } from '../inbound/
13
13
  import { getOrCreateConversation } from '../memory/conversation-key-store.js';
14
14
  import { queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
15
15
  import { upsertBinding } from '../memory/external-conversation-store.js';
16
+ import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
16
17
  import { isGuardian } from '../runtime/channel-guardian-service.js';
17
18
  import { getSecureKey } from '../security/secure-keys.js';
18
19
  import { getLogger } from '../util/logger.js';
@@ -489,6 +490,17 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
489
490
  // Expire any pending questions so they don't linger
490
491
  expirePendingQuestions(callSessionId);
491
492
 
493
+ // Revoke any scoped approval grants bound to this call session.
494
+ // Revoke by both callSessionId and conversationId because the
495
+ // guardian-approval-interception minting path sets callSessionId: null
496
+ // but always sets conversationId.
497
+ try {
498
+ revokeScopedApprovalGrantsForContext({ callSessionId });
499
+ revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
500
+ } catch (err) {
501
+ log.warn({ err, callSessionId }, 'Failed to revoke scoped grants on call cancel');
502
+ }
503
+
492
504
  // Re-check final status: a concurrent transition (e.g. Twilio callback) may have
493
505
  // moved the session to a terminal state before our update, causing it to be skipped.
494
506
  const updated = getCallSession(callSessionId);